Webhooks

Receive real-time notifications at your server whenever an action happens on the payment gateway: sales, authorizations, captures, voids, refunds, tokenizations, and ACH status changes.

Overview

Each time a gateway action completes, an HTTP POST request is sent to the webhook URL configured for the location. The request body is a JSON object describing the event, and the headers carry a signature you use to confirm the notification is authentic.

PropertyValue
MethodPOST
Content typeapplication/json
ProtocolHTTPS (recommended)
TriggerOne request per gateway event

Supported events

EventDescription
gateway.sale.completedA sale (authorization + capture in one step) finished.
gateway.authorization.completedAn authorization (funds hold) finished.
gateway.capture.completedA capture of a previous authorization finished.
gateway.void.completedA transaction was voided.
gateway.refund.completedA refund was processed.
gateway.tokenize.completedA card was tokenized and stored.
ach.status.changedThe status of an ACH transaction changed.

The event type is always present in the event field of the payload.

Request headers

HeaderValueDescription
content-typeapplication/jsonThe body is always JSON.
content-length<CONTENT_LENGTH>Size of the body in bytes.
user-agent<USER_AGENT>HTTP client used to send the webhook.
x-softpoint-client-key<CLIENT_KEY>Public key identifying the client/location the webhook belongs to.
x-softpoint-timestamp<UNIX_TIMESTAMP>Unix timestamp (seconds) of when the signature was generated.
x-softpoint-signaturet=<UNIX_TIMESTAMP>,v1=<SIGNATURE>Signature used to verify the request. See Verifying the signature.

Verifying the signature

Every request is signed so you can confirm it was sent by the gateway and was not altered in transit.

🚧

Always verify before trusting the payload

Never process a webhook whose signature fails verification. Keep your signing secret private: never expose it in client-side code, logs, or public repositories.

Signature header format

x-softpoint-signature: t=<UNIX_TIMESTAMP>,v1=<SIGNATURE>
PartMeaning
tUnix timestamp in seconds. Matches x-softpoint-timestamp.
v1Hex-encoded HMAC-SHA256 signature.

Verification steps

  1. Read the raw request body exactly as received.
  2. Parse the x-softpoint-signature header and extract t and v1.
  3. Build the signed payload by joining the timestamp and the raw body with a period: <t>.<raw_body>.
  4. Compute an HMAC-SHA256 of the signed payload using your webhook signing secret as the key, and hex-encode the result.
  5. Compare your result with v1 using a constant-time comparison.
  6. Reject the request if the timestamp is older than your tolerance window (300 seconds recommended) to protect against replay attacks.
❗️

Use the raw body

Do not parse and re-serialize the JSON before computing the signature. Any change in whitespace or key order will make verification fail.

Code examples

<?php
$secret    = getenv('SOFTPOINT_WEBHOOK_SECRET');
$rawBody   = file_get_contents('php://input');
$header    = $_SERVER['HTTP_X_SOFTPOINT_SIGNATURE'] ?? '';
$tolerance = 300; // seconds

$parts = [];
foreach (explode(',', $header) as $pair) {
    [$k, $v] = array_pad(explode('=', $pair, 2), 2, null);
    $parts[trim($k)] = trim((string) $v);
}

if (empty($parts['t']) || empty($parts['v1'])) {
    http_response_code(400);
    exit('Invalid signature header');
}

if (abs(time() - (int) $parts['t']) > $tolerance) {
    http_response_code(400);
    exit('Timestamp outside tolerance');
}

$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);

if (!hash_equals($expected, $parts['v1'])) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true);
// Process $event ...
http_response_code(200);
const express = require('express');
const crypto = require('crypto');

const app = express();
const SECRET = process.env.SOFTPOINT_WEBHOOK_SECRET;
const TOLERANCE = 300; // seconds

app.post('/webhooks/gateway', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('x-softpoint-signature') || '';
  const parts = Object.fromEntries(
    header.split(',').map(p => p.split('=').map(s => s.trim()))
  );

  if (!parts.t || !parts.v1) return res.status(400).send('Invalid signature header');

  if (Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)) > TOLERANCE) {
    return res.status(400).send('Timestamp outside tolerance');
  }

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${parts.t}.${req.body.toString('utf8')}`)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Process event ...
  res.sendStatus(200);
});

app.listen(3000);

Responding to webhooks

Return a 2xx status code (for example 200 OK) as soon as the request has been received and verified. Any other status code, or a timeout, is treated as a failed delivery.

📘

Best practices

  • Respond fast. Queue the event and return 200 immediately; do heavy processing asynchronously.
  • Be idempotent. The same event may be delivered more than once. Use transaction_id + event to detect duplicates (token + event for tokenizations, and transaction_id + transaction_status for ACH).
  • Don't rely on order. Events may arrive out of order; for example, a capture may arrive before you finish processing its authorization.

Payload fields

Every payload contains event and location_id. The table below shows which fields each event includes.

FieldTypeSaleAuthCaptureVoidRefundTokenizeACH
eventstring✓✓✓✓✓✓✓
location_idinteger✓✓✓✓✓✓✓
transaction_idinteger✓✓✓✓✓✓
original_transaction_idinteger✓✓✓
amountstring✓✓✓✓✓
currencystring✓✓✓✓✓
processor_codestring✓✓✓✓✓
transaction_statusstring✓✓✓✓✓✓
guest_idinteger | null✓✓✓✓✓✓✓
tokenstring | null✓✓✓✓✓✓✓
card_type_idinteger✓
card_binstring✓
last4string✓
card_exp_monthstring✓
card_exp_yearstring✓
customer_id_externalstring✓
🚧

Parsing notes

  • amount is a string with two decimals. Parse it as a decimal, never as a float.
  • currency is an ISO 4217 code that may arrive in upper or lower case. Compare it case-insensitively.
  • guest_id and token can be null. Always handle the null case.

Events

gateway.sale.completed

Sent when a sale (authorization and capture in a single step) has been processed.

{
  "location_id": <LOCATION_ID>,
  "transaction_id": <TRANSACTION_ID>,
  "amount": "<AMOUNT>",
  "currency": "<CURRENCY>",
  "processor_code": "<PROCESSOR_CODE>",
  "transaction_status": "<TRANSACTION_STATUS>",
  "guest_id": <GUEST_ID | null>,
  "token": "<TOKEN>" | null,
  "event": "gateway.sale.completed"
}
POST <YOUR_WEBHOOK_PATH> HTTP/1.1
Host: <YOUR_DOMAIN>
Content-Type: application/json
Content-Length: <CONTENT_LENGTH>
User-Agent: <USER_AGENT>
X-Softpoint-Client-Key: <CLIENT_KEY>
X-Softpoint-Timestamp: <UNIX_TIMESTAMP>
X-Softpoint-Signature: t=<UNIX_TIMESTAMP>,v1=<SIGNATURE>
FieldTypeDescription
location_idintegerLocation where the sale was made.
transaction_idintegerID of the sale transaction.
amountstringTotal amount charged.
currencystringCurrency of the sale.
processor_codestringProcessor that handled the sale.
transaction_statusstringResult of the sale.
guest_idinteger | nullGuest linked to the sale, if any.
tokenstring | nullPayment token used, if any.
eventstringAlways gateway.sale.completed.

gateway.authorization.completed

Sent when an authorization has been processed. An authorization places a hold on the funds but does not move them. The funds are later collected with a capture or released with a void.

{
  "location_id": <LOCATION_ID>,
  "transaction_id": <TRANSACTION_ID>,
  "amount": "<AMOUNT>",
  "currency": "<CURRENCY>",
  "processor_code": "<PROCESSOR_CODE>",
  "transaction_status": "<TRANSACTION_STATUS>",
  "guest_id": <GUEST_ID | null>,
  "token": "<TOKEN>" | null,
  "event": "gateway.authorization.completed"
}
FieldTypeDescription
location_idintegerLocation where the authorization was made.
transaction_idintegerID of the authorization. Store it: captures and voids reference it as original_transaction_id.
amountstringAmount authorized (held).
currencystringCurrency of the authorization.
processor_codestringProcessor that handled the authorization.
transaction_statusstringResult of the authorization.
guest_idinteger | nullGuest linked to the authorization, if any.
tokenstring | nullPayment token used, if any.
eventstringAlways gateway.authorization.completed.

gateway.capture.completed

Sent when a capture of a previous authorization has been processed. The capture is a new transaction with its own transaction_id and points back to the authorization through original_transaction_id.

📘

Partial captures

The captured amount can be lower than the authorized amount.

{
  "location_id": <LOCATION_ID>,
  "transaction_id": <TRANSACTION_ID>,
  "original_transaction_id": <ORIGINAL_TRANSACTION_ID>,
  "amount": "<AMOUNT>",
  "currency": "<CURRENCY>",
  "processor_code": "<PROCESSOR_CODE>",
  "transaction_status": "<TRANSACTION_STATUS>",
  "guest_id": <GUEST_ID | null>,
  "token": "<TOKEN>" | null,
  "event": "gateway.capture.completed"
}
FieldTypeDescription
location_idintegerLocation where the capture was made.
transaction_idintegerID of the capture transaction.
original_transaction_idintegerID of the authorization being captured.
amountstringAmount captured. May be less than the authorized amount.
currencystringCurrency of the capture.
processor_codestringProcessor that handled the capture.
transaction_statusstringResult of the capture.
guest_idinteger | nullGuest linked to the capture, if any.
tokenstring | nullPayment token used, if any.
eventstringAlways gateway.capture.completed.

gateway.void.completed

Sent when a transaction has been voided. A void cancels a transaction before it settles, for example by releasing an authorization hold. The void is a new transaction with its own transaction_id and points back to the voided transaction through original_transaction_id.

{
  "location_id": <LOCATION_ID>,
  "transaction_id": <TRANSACTION_ID>,
  "original_transaction_id": <ORIGINAL_TRANSACTION_ID>,
  "amount": "<AMOUNT>",
  "currency": "<CURRENCY>",
  "processor_code": "<PROCESSOR_CODE>",
  "transaction_status": "<TRANSACTION_STATUS>",
  "guest_id": <GUEST_ID | null>,
  "token": "<TOKEN>" | null,
  "event": "gateway.void.completed"
}
FieldTypeDescription
location_idintegerLocation where the void was made.
transaction_idintegerID of the void transaction.
original_transaction_idintegerID of the transaction that was voided.
amountstringAmount voided.
currencystringCurrency of the void.
processor_codestringProcessor that handled the void.
transaction_statusstringResult of the void.
guest_idinteger | nullGuest linked to the voided transaction, if any.
tokenstring | nullPayment token used, if any.
eventstringAlways gateway.void.completed.

gateway.refund.completed

Sent when a refund has been processed. A refund returns funds from a transaction that has already settled. The refund is a new transaction with its own transaction_id and points back to the refunded transaction through original_transaction_id.

📘

Void vs. refund

Use a void to cancel a transaction before it settles; use a refund to return money after it settles. Each produces its own event.

{
  "location_id": <LOCATION_ID>,
  "transaction_id": <TRANSACTION_ID>,
  "original_transaction_id": <ORIGINAL_TRANSACTION_ID>,
  "amount": "<AMOUNT>",
  "currency": "<CURRENCY>",
  "processor_code": "<PROCESSOR_CODE>",
  "transaction_status": "<TRANSACTION_STATUS>",
  "guest_id": <GUEST_ID | null>,
  "token": "<TOKEN>" | null,
  "event": "gateway.refund.completed"
}
FieldTypeDescription
location_idintegerLocation where the refund was made.
transaction_idintegerID of the refund transaction.
original_transaction_idintegerID of the transaction being refunded.
amountstringAmount refunded. May be less than the original amount (partial refund).
currencystringCurrency of the refund.
processor_codestringProcessor that handled the refund.
transaction_statusstringResult of the refund.
guest_idinteger | nullGuest linked to the refunded transaction, if any.
tokenstring | nullPayment token used, if any.
eventstringAlways gateway.refund.completed.

gateway.tokenize.completed

Sent when a card has been tokenized and stored. No money moves in this event, so the payload has no transaction_id, amount, currency, processor_code, or transaction_status. Instead, it carries the token and non-sensitive card details you can show to the customer (for example, brand and last four digits).

{
  "location_id": <LOCATION_ID>,
  "token": "<TOKEN>",
  "card_type_id": <CARD_TYPE_ID>,
  "guest_id": <GUEST_ID | null>,
  "card_bin": "<CARD_BIN>",
  "last4": "<LAST4>",
  "card_exp_month": "<MM>",
  "card_exp_year": "<YY>",
  "customer_id_external": "<CUSTOMER_ID_EXTERNAL>",
  "event": "gateway.tokenize.completed"
}
FieldTypeDescription
location_idintegerLocation where the card was tokenized.
tokenstringToken that represents the stored card. Use it for future charges instead of card data.
card_type_idintegerCard brand identifier. See Card types.
guest_idinteger | nullGuest the card belongs to, if any.
card_binstringFirst 6 digits of the card (Bank Identification Number).
last4stringLast 4 digits of the card.
card_exp_monthstringExpiration month, two digits (01 to 12).
card_exp_yearstringExpiration year, two digits (YY). Add 2000 to get the full year.
customer_id_externalstringCustomer ID in your own system, as sent when the card was tokenized. Always a string, even if numeric.
eventstringAlways gateway.tokenize.completed.
📘

Keep card fields as strings

Store card_bin, last4, card_exp_month, and card_exp_year as strings to preserve leading zeros.

Card types

card_type_idCard brand
<CARD_TYPE_ID>To be confirmed

ach.status.changed

Sent when the status of an ACH transaction changes. ACH payments settle over several days, so a single ACH transaction can produce this event more than once as its status moves through its lifecycle. Always use the latest transaction_status received for a given transaction_id.

This payload has no amount, currency, processor_code, or original_transaction_id. Use transaction_id to look up the transaction details in your system.

{
  "transaction_id": <TRANSACTION_ID>,
  "location_id": <LOCATION_ID>,
  "guest_id": <GUEST_ID | null>,
  "token": "<TOKEN>" | null,
  "transaction_status": "<TRANSACTION_STATUS>",
  "event": "ach.status.changed"
}
FieldTypeDescription
transaction_idintegerID of the ACH transaction whose status changed.
location_idintegerLocation the ACH transaction belongs to.
guest_idinteger | nullGuest linked to the ACH transaction, if any.
tokenstring | nullToken of the bank account used, if any.
transaction_statusstringNew status of the ACH transaction.
eventstringAlways ach.status.changed.
🚧

Handle repeated events

Because one ACH transaction can generate several ach.status.changed events, deduplicate on transaction_id + transaction_status, not on transaction_id alone.

Testing

To inspect requests before building your endpoint, point the location's webhook URL to a request-inspection service such as webhook.site, trigger a gateway action, and review the headers and body that arrive. Once your endpoint is ready, switch the URL to your own server and confirm that signature verification passes.