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.
| Property | Value |
|---|---|
| Method | POST |
| Content type | application/json |
| Protocol | HTTPS (recommended) |
| Trigger | One request per gateway event |
Supported events
| Event | Description |
|---|---|
gateway.sale.completed | A sale (authorization + capture in one step) finished. |
gateway.authorization.completed | An authorization (funds hold) finished. |
gateway.capture.completed | A capture of a previous authorization finished. |
gateway.void.completed | A transaction was voided. |
gateway.refund.completed | A refund was processed. |
gateway.tokenize.completed | A card was tokenized and stored. |
ach.status.changed | The status of an ACH transaction changed. |
The event type is always present in the event field of the payload.
Request headers
| Header | Value | Description |
|---|---|---|
content-type | application/json | The 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-signature | t=<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 payloadNever 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>| Part | Meaning |
|---|---|
t | Unix timestamp in seconds. Matches x-softpoint-timestamp. |
v1 | Hex-encoded HMAC-SHA256 signature. |
Verification steps
- Read the raw request body exactly as received.
- Parse the
x-softpoint-signatureheader and extracttandv1. - Build the signed payload by joining the timestamp and the raw body with a period:
<t>.<raw_body>. - Compute an HMAC-SHA256 of the signed payload using your webhook signing secret as the key, and hex-encode the result.
- Compare your result with
v1using a constant-time comparison. - Reject the request if the timestamp is older than your tolerance window (300 seconds recommended) to protect against replay attacks.
Use the raw bodyDo 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
200immediately; do heavy processing asynchronously.- Be idempotent. The same event may be delivered more than once. Use
transaction_id+eventto detect duplicates (token+eventfor tokenizations, andtransaction_id+transaction_statusfor 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.
| Field | Type | Sale | Auth | Capture | Void | Refund | Tokenize | ACH |
|---|---|---|---|---|---|---|---|---|
event | string | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
location_id | integer | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
transaction_id | integer | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
original_transaction_id | integer | ✓ | ✓ | ✓ | ||||
amount | string | ✓ | ✓ | ✓ | ✓ | ✓ | ||
currency | string | ✓ | ✓ | ✓ | ✓ | ✓ | ||
processor_code | string | ✓ | ✓ | ✓ | ✓ | ✓ | ||
transaction_status | string | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | |
guest_id | integer | null | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
token | string | null | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
card_type_id | integer | ✓ | ||||||
card_bin | string | ✓ | ||||||
last4 | string | ✓ | ||||||
card_exp_month | string | ✓ | ||||||
card_exp_year | string | ✓ | ||||||
customer_id_external | string | ✓ |
Parsing notes
amountis a string with two decimals. Parse it as a decimal, never as a float.currencyis an ISO 4217 code that may arrive in upper or lower case. Compare it case-insensitively.guest_idandtokencan benull. 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>| Field | Type | Description |
|---|---|---|
location_id | integer | Location where the sale was made. |
transaction_id | integer | ID of the sale transaction. |
amount | string | Total amount charged. |
currency | string | Currency of the sale. |
processor_code | string | Processor that handled the sale. |
transaction_status | string | Result of the sale. |
guest_id | integer | null | Guest linked to the sale, if any. |
token | string | null | Payment token used, if any. |
event | string | Always 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"
}| Field | Type | Description |
|---|---|---|
location_id | integer | Location where the authorization was made. |
transaction_id | integer | ID of the authorization. Store it: captures and voids reference it as original_transaction_id. |
amount | string | Amount authorized (held). |
currency | string | Currency of the authorization. |
processor_code | string | Processor that handled the authorization. |
transaction_status | string | Result of the authorization. |
guest_id | integer | null | Guest linked to the authorization, if any. |
token | string | null | Payment token used, if any. |
event | string | Always 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 capturesThe captured
amountcan 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"
}| Field | Type | Description |
|---|---|---|
location_id | integer | Location where the capture was made. |
transaction_id | integer | ID of the capture transaction. |
original_transaction_id | integer | ID of the authorization being captured. |
amount | string | Amount captured. May be less than the authorized amount. |
currency | string | Currency of the capture. |
processor_code | string | Processor that handled the capture. |
transaction_status | string | Result of the capture. |
guest_id | integer | null | Guest linked to the capture, if any. |
token | string | null | Payment token used, if any. |
event | string | Always 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"
}| Field | Type | Description |
|---|---|---|
location_id | integer | Location where the void was made. |
transaction_id | integer | ID of the void transaction. |
original_transaction_id | integer | ID of the transaction that was voided. |
amount | string | Amount voided. |
currency | string | Currency of the void. |
processor_code | string | Processor that handled the void. |
transaction_status | string | Result of the void. |
guest_id | integer | null | Guest linked to the voided transaction, if any. |
token | string | null | Payment token used, if any. |
event | string | Always 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. refundUse 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"
}| Field | Type | Description |
|---|---|---|
location_id | integer | Location where the refund was made. |
transaction_id | integer | ID of the refund transaction. |
original_transaction_id | integer | ID of the transaction being refunded. |
amount | string | Amount refunded. May be less than the original amount (partial refund). |
currency | string | Currency of the refund. |
processor_code | string | Processor that handled the refund. |
transaction_status | string | Result of the refund. |
guest_id | integer | null | Guest linked to the refunded transaction, if any. |
token | string | null | Payment token used, if any. |
event | string | Always 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"
}| Field | Type | Description |
|---|---|---|
location_id | integer | Location where the card was tokenized. |
token | string | Token that represents the stored card. Use it for future charges instead of card data. |
card_type_id | integer | Card brand identifier. See Card types. |
guest_id | integer | null | Guest the card belongs to, if any. |
card_bin | string | First 6 digits of the card (Bank Identification Number). |
last4 | string | Last 4 digits of the card. |
card_exp_month | string | Expiration month, two digits (01 to 12). |
card_exp_year | string | Expiration year, two digits (YY). Add 2000 to get the full year. |
customer_id_external | string | Customer ID in your own system, as sent when the card was tokenized. Always a string, even if numeric. |
event | string | Always gateway.tokenize.completed. |
Keep card fields as stringsStore
card_bin,last4,card_exp_month, andcard_exp_yearas strings to preserve leading zeros.
Card types
card_type_id | Card 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"
}| Field | Type | Description |
|---|---|---|
transaction_id | integer | ID of the ACH transaction whose status changed. |
location_id | integer | Location the ACH transaction belongs to. |
guest_id | integer | null | Guest linked to the ACH transaction, if any. |
token | string | null | Token of the bank account used, if any. |
transaction_status | string | New status of the ACH transaction. |
event | string | Always ach.status.changed. |
Handle repeated eventsBecause one ACH transaction can generate several
ach.status.changedevents, deduplicate ontransaction_id+transaction_status, not ontransaction_idalone.
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.

