RefundsRefund Webhooks

Refund Webhooks

SaySwitch sends refund status notifications to the webhook URL configured on your merchant dashboard. A refund request does not accept a separate callback URL.

Use webhooks together with refund status verification. An HTTP 202 response from refund initiation means the request was accepted, not that the refund completed. See the Refund Overview for the complete funding and processing lifecycle.

Status Lifecycle

awaiting_settlement -> pending
pending -> processing -> success
awaiting_settlement -> rejected
pending -> rejected
processing -> failed
failed -> processing
failed -> rejected

Status Payloads

Select a status to see the event delivered for that stage.

The USD 1.00 fee in these samples is illustrative. The actual refund fee is determined by the merchant’s agreement and is returned in data.fee.

refund.awaiting_settlement means the refund was accepted, but part or all of its funding is reserved from pending funds. Processing starts automatically after the full reservation has settled.

{
  "event": "refund.awaiting_settlement",
  "data": {
    "refund_reference": "REF_2026_001",
    "transaction_reference": "USD_TXN_2026_001",
    "amount": "100.00",
    "fee": "1.00",
    "total_reserved": "101.00",
    "currency": "USD",
    "status": "awaiting_settlement",
    "funding": {
      "status": "awaiting_settlement",
      "available_reserved": "40.00",
      "pending_reserved": "61.00",
      "pending_funded_amount": "0.00",
      "pending_remaining": "61.00",
      "funded_at": null
    },
    "external_reference": null,
    "requested_at": "2026-09-28T10:00:00+01:00",
    "updated_at": "2026-09-28T10:00:00+01:00"
  }
}

Refund status describes the processing lifecycle. funding.status describes whether reserved money is ready and can be awaiting_settlement, funded, or released. Merchants do not manually change either status.

Signature Headers

HeaderDescription
payloadSignatureHMAC SHA512 signature of the raw JSON body using your secret key.
merchantSignatureHMAC SHA512 signature of your public key using your secret key.
timestampTime when the webhook was sent.
Content-Typeapplication/json
payloadSignature = HMAC_SHA512(raw_json_body, merchant_secret_key)
merchantSignature = HMAC_SHA512(merchant_public_key, merchant_secret_key)

Use the test key pair for test refunds and the live key pair for live refunds. Always calculate payloadSignature from the raw request bytes before parsing the JSON. See the general webhook signature guide.

Webhook Handlers

The examples verify both signatures and accept all six refund events. Replace each example’s placeholder persistence function with a durable, idempotent database operation.

import crypto from 'node:crypto';
import http from 'node:http';
 
const supportedEvents = new Set([
  'refund.awaiting_settlement',
  'refund.pending',
  'refund.processing',
  'refund.success',
  'refund.failed',
  'refund.rejected'
]);
 
function validSignature(rawBody, headers) {
  const payloadSignature = crypto
    .createHmac('sha512', process.env.SAYSWITCH_SECRET_KEY)
    .update(rawBody)
    .digest('hex');
  const merchantSignature = crypto
    .createHmac('sha512', process.env.SAYSWITCH_SECRET_KEY)
    .update(process.env.SAYSWITCH_PUBLIC_KEY)
    .digest('hex');
 
  return (
    headers.payloadsignature === payloadSignature &&
    headers.merchantsignature === merchantSignature
  );
}
 
async function saveRefundStatusOnce(event, data) {
  // Use event, refund_reference, and updated_at as an idempotency key.
  console.log(event, data.refund_reference, data.status);
http.createServer((request, response) => {
  const chunks = [];
  request.on('data', chunk => chunks.push(chunk));
  request.on('end', async () => {
    const rawBody = Buffer.concat(chunks);
 
    if (!validSignature(rawBody, request.headers)) {
      response.writeHead(401).end('Invalid signature');
      return;
    }
 
    const payload = JSON.parse(rawBody.toString('utf8'));
    if (!supportedEvents.has(payload.event)) {
      response.writeHead(200).end('Ignored');
      return;
    }
 
    await saveRefundStatusOnce(payload.event, payload.data);
    response.writeHead(200).end('OK');
  });
}).listen(3000);

Test the Handler

This script signs and sends one example of every refund event to your local or test webhook endpoint:

WEBHOOK_URL="https://merchant.example.com/webhooks/sayswitch"
SECRET_KEY="YOUR_TEST_SECRET_KEY"
PUBLIC_KEY="YOUR_TEST_PUBLIC_KEY"
MERCHANT_SIGNATURE=$(printf '%s' "$PUBLIC_KEY" | openssl dgst -sha512 -hmac "$SECRET_KEY" -hex | awk '{print $2}')
 
for STATUS in awaiting_settlement pending processing success failed rejected; do
  FUNDING_STATUS="funded"
  PENDING_FUNDED="61.00"
  PENDING_REMAINING="0.00"
  FUNDED_AT='"2026-09-28T11:00:00+01:00"'
 
  if [ "$STATUS" = "awaiting_settlement" ]; then
    FUNDING_STATUS="awaiting_settlement"
    PENDING_FUNDED="0.00"
    PENDING_REMAINING="61.00"
    FUNDED_AT=null
  elif [ "$STATUS" = "rejected" ]; then
    FUNDING_STATUS="released"
  fi
 
  BODY=$(printf '{"event":"refund.%s","data":{"refund_reference":"REF_2026_001","transaction_reference":"USD_TXN_2026_001","amount":"100.00","fee":"1.00","total_reserved":"101.00","currency":"USD","status":"%s","funding":{"status":"%s","available_reserved":"40.00","pending_reserved":"61.00","pending_funded_amount":"%s","pending_remaining":"%s","funded_at":%s},"external_reference":null,"requested_at":"2026-09-28T10:00:00+01:00","updated_at":"2026-09-28T13:00:00+01:00"}}' "$STATUS" "$STATUS" "$FUNDING_STATUS" "$PENDING_FUNDED" "$PENDING_REMAINING" "$FUNDED_AT")
  PAYLOAD_SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha512 -hmac "$SECRET_KEY" -hex | awk '{print $2}')
 
  curl --request POST \
    --url "$WEBHOOK_URL" \
    --header "Content-Type: application/json" \
    --header "payloadSignature: $PAYLOAD_SIGNATURE" \
    --header "merchantSignature: $MERCHANT_SIGNATURE" \
    --header "timestamp: 2026-09-27T13:00:00+01:00" \
    --data-raw "$BODY"
done

Processing Rules

  • Verify both signatures before parsing or acting on the event.
  • Use the key pair that matches the refund’s test or live environment.
  • Make processing idempotent because the same event can be delivered more than once.
  • Key idempotency by event, refund_reference, and updated_at, or use an equivalent durable constraint.
  • Return an HTTP 2xx response promptly after safely recording the event.
  • Treat awaiting_settlement, pending, processing, and failed as incomplete states.
  • Verify final status with GET /api/v1/refund/status/{refund_reference}.
  • Provide value to the customer only after independently confirming the business action your integration requires.
  • Never expose secret keys in frontend code or logs.