DRAFTThe sandbox is not live yet. Every example here was returned by the platform's code in a test run on 2026-10-28, priced with example fees: no fee amount in them is an SMP price. Paths and fields can change before launch. Live keys are issued only after our bank program is approved.Sandbox not live yet · examples from a test run on 2026-10-28 · example fees

Guide

Signed webhooks

The events we send, how each delivery is signed, and verification code in Node, PHP and Python that is tested against our signer.

Subscribe

Register an HTTPS endpoint and the events you want with POST /v1/webhooks; 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 /v1/webhooks/{id}/test.

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.

HeaderValue
X-SMP-EventThe event name, the same as event in the body.
X-SMP-Delivery-IDThe delivery's ID. Retries of the same delivery reuse it.
X-SMP-TimestampUnix time in seconds when this attempt was signed.
X-SMP-SignatureHex HMAC-SHA256 of {X-SMP-Timestamp}.{raw body}, keyed with your whole signing secret, whsec_ included.
User-AgentSocialMatterPayments-Webhook/1.0
The delivery · payment.settled
POST to your endpoint · 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

verify.mjs · Node 18+
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

verify.php · PHP 8+
<?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

verify.py · Python 3.9+
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

The platform's 18 events, read from its event list; the keys are the ones the code that sends each event puts in data.
EventKeys in data
payment.authorizedpayment_id, amount_cents, fee_cents, net_cents, estimated_settlement, external_order_id
payment.declinedpayment_id, amount_cents, decline_reason, decline_message, external_order_id
payment.settledpayment_id, amount_cents, fee_cents, net_cents, settled_at, release_after, external_order_id
payment.releasedpayment_id, net_cents, external_order_id
payment.returnedpayment_id, return_id, return_code, return_reason, category, amount_cents, return_fee_cents, unauthorized_return_fee_cents, covered, covered_cents, external_order_id
payment.voidedpayment_id, amount_cents
payment.refundedpayment_id, refund_id, amount_cents, refunded_cents
payout.initiatedpayout_id, amount_cents, fee_cents, net_cents, returns_netted_cents, fees_netted_cents, fees_included, payments_included
payout.settledpayout_id, amount_cents, payments_paid_out
payout.failedpayout_id, amount_cents, reason
fee.assessedfee_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.paidinvoice_id, status, amount_cents, paid_cents, received_now_cents, remaining_cents, overpaid_cents, held_at_bank_cents, external_id
invoice.partially_paidinvoice_id, status, amount_cents, paid_cents, received_now_cents, remaining_cents, overpaid_cents, held_at_bank_cents, external_id
invoice.overdueinvoice_id, amount_cents, paid_cents, due_date, external_id
bank_account.disconnectedbank_account_id, payer_id, reason
bank_account.relink_requiredbank_account_id, payer_id, reason, is_tokenized, consent_expires_at, disconnect_at, debits_stop_at
bank_account.relink_recommendedbank_account_id, payer_id, reason, is_tokenized, consent_expires_at, disconnect_at, debits_stop_at
bank_account.owner_changedbank_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 /v1/webhooks/{id}/test, with data set to {"test": true}, and can't be subscribed to.