Subscribe
Register an HTTPS endpoint and the events you want with POST /; use "*" for every event. Add site_id to hear only one site's payments; a site's own key sees and changes only that site's subscriptions. The response includes the subscription's signing secret, whsec_ followed by 64 hex characters. It is shown once. On the live API the endpoint must be on the one website your account is approved for; anywhere else answers 400 validation_error.
Send yourself a signed webhook.test any time with POST /.
What a delivery looks like
Each event is a POST with a JSON body of four keys: id, event, created_at and data. The id is the delivery's ID and equals the X-SMP-Delivery-ID header.
| Header | Value |
|---|---|
X-SMP-Event | The event name, the same as event in the body. |
X-SMP-Delivery-ID | The delivery's ID. Retries of the same delivery reuse it. |
X-SMP-Timestamp | Unix time in seconds when this attempt was signed. |
X-SMP-Signature | Hex HMAC-SHA256 of {X-SMP-Timestamp}.{raw body}, keyed with your whole signing secret, whsec_ included. |
User-Agent | SocialMatterPayments-Webhook/1.0 |
The delivery · payment.settled
POST /smp/webhooks
Content-Type: application/json
User-Agent: SocialMatterPayments-Webhook/1.0
X-SMP-Event: payment.settled
X-SMP-Delivery-ID: 27a244be-357b-440f-941a-abc7bf5f9fee
X-SMP-Timestamp: 1793146956
X-SMP-Signature: 5d445074ffbd99d1df1f0ab4d4056746c1e8c79b1e436ce799eccd89027cdaed
{
"id": "27a244be-357b-440f-941a-abc7bf5f9fee",
"event": "payment.settled",
"created_at": "2026-10-28T00:23:36.559Z",
"data": {
"payment_id": "3c8029c9-00de-481f-aff2-4e80839dea08",
"amount_cents": 8400,
"fee_cents": 109,
"net_cents": 8291,
"settled_at": "2026-10-28T00:23:35.518Z",
"release_after": "2026-10-28T00:23:35.518Z",
"external_order_id": "order_10482"
}
}
Verify every delivery
Use the raw body. Compute the signature over the bytes you received, before parsing them. Parsing and re-serialising JSON changes the bytes and the signature stops matching.
Compare in constant time, and refuse a timestamp more than 300 seconds from your clock: an old, valid delivery replayed later then fails.
Process each X-SMP-Delivery-ID once. A delivery can arrive again after a timeout on your side.
Each function below returns true only for a genuine, fresh delivery. All three run in our test suite against deliveries signed by the platform's own signer, and must reject a changed body, a wrong secret and a stale timestamp.
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the body exactly as it arrived (string or Buffer), before
// JSON.parse. headers: the request's headers (Node lowercases the names).
export function verifySmpSignature(rawBody, headers, secret, options = {}) {
const { toleranceSeconds = 300, now = Date.now() } = options;
const signature = headers['x-smp-signature'];
const timestamp = headers['x-smp-timestamp'];
if (typeof signature !== 'string' || !/^\d+$/.test(timestamp ?? '')) {
return false;
}
if (Math.abs(now / 1000 - Number(timestamp)) > toleranceSeconds) {
return false;
}
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
const given = Buffer.from(signature, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
PHP
<?php
// $rawBody: file_get_contents('php://input'), before any json_decode.
// $signature: $_SERVER['HTTP_X_SMP_SIGNATURE']
// $timestamp: $_SERVER['HTTP_X_SMP_TIMESTAMP']
function smp_verify_signature(
string $rawBody,
string $signature,
string $timestamp,
string $secret,
int $toleranceSeconds = 300,
?int $now = null
): bool {
if (!ctype_digit($timestamp)) {
return false;
}
$now = $now ?? time();
if (abs($now - (int) $timestamp) > $toleranceSeconds) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, strtolower($signature));
}
Python
import hashlib
import hmac
import time
from typing import Optional
def verify_smp_signature(raw_body: bytes, signature: str, timestamp: str,
secret: str, tolerance_seconds: int = 300,
now: Optional[float] = None) -> bool:
"""raw_body: the body exactly as it arrived (Flask: request.get_data())."""
if not (timestamp.isascii() and timestamp.isdigit()):
return False
current = time.time() if now is None else now
if abs(current - int(timestamp)) > tolerance_seconds:
return False
message = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.lower())
To test your own verifier, the delivery above was signed with the placeholder secret shown in the quickstart: pass the example's own timestamp as the current time and it verifies.
Answering and retries
Answer with any 2xx within 10 seconds and the delivery is done. Anything else, or no answer, is tried again: up to 5 attempts in all, the retries after 30 seconds, 2 minutes, 10 minutes and 1 hour. A retry sends the same body and the same X-SMP-Delivery-ID, with a new timestamp and signature. After the fifth failed attempt the delivery stops.
Answer quickly and do slow work afterwards. Webhooks are a signal: when one matters, confirm the state with a GET on the payment or payout.
Events
| Event | Keys in data |
|---|---|
payment.authorized | payment_id, amount_cents, fee_cents, net_cents, estimated_settlement, external_order_id |
payment.declined | payment_id, amount_cents, decline_reason, decline_message, external_order_id |
payment.settled | payment_id, amount_cents, fee_cents, net_cents, settled_at, release_after, external_order_id |
payment.released | payment_id, net_cents, external_order_id |
payment.returned | payment_id, return_id, return_code, return_reason, category, amount_cents, return_fee_cents, unauthorized_return_fee_cents, covered, covered_cents, external_order_id |
payment.voided | payment_id, amount_cents |
payment.refunded | payment_id, refund_id, amount_cents, refunded_cents |
payout.initiated | payout_id, amount_cents, fee_cents, net_cents, returns_netted_cents, fees_netted_cents, fees_included, payments_included |
payout.settled | payout_id, amount_cents, payments_paid_out |
payout.failed | payout_id, amount_cents, reason |
fee.assessed | fee_assessment_id, code, amount_cents, bank_account_id, fee_collection_id, base_amount_cents, period, payment_id, change_code, invoice_id, refund_id, payout_id, return_id |
invoice.paid | invoice_id, status, amount_cents, paid_cents, received_now_cents, remaining_cents, overpaid_cents, held_at_bank_cents, external_id |
invoice.partially_paid | invoice_id, status, amount_cents, paid_cents, received_now_cents, remaining_cents, overpaid_cents, held_at_bank_cents, external_id |
invoice.overdue | invoice_id, amount_cents, paid_cents, due_date, external_id |
bank_account.disconnected | bank_account_id, payer_id, reason |
bank_account.relink_required | bank_account_id, payer_id, reason, is_tokenized, consent_expires_at, disconnect_at, debits_stop_at |
bank_account.relink_recommended | bank_account_id, payer_id, reason, is_tokenized, consent_expires_at, disconnect_at, debits_stop_at |
bank_account.owner_changed | bank_account_id, payer_id, owner_name, previous_bank_account_id, previous_owner_name, identity_match_score |
Subscribe to any of them, or "*" for all 18. webhook.test is not one of them: it is sent only when you call POST /, with data set to {"test": true}, and can't be subscribed to.
