Outbound webhooks
Receive payment events on your server.
Outbound webhooks let ShiftxPay notify your server when something happens to a payment — a charge succeeds, a refund settles, a dispute opens. You register an HTTPS endpoint, subscribe it to one or more event types, and ShiftxPay POSTs a signed JSON body to it each time a matching event fires.
All endpoints below are gated by your API key and live under
https://api.lite.shiftxpay.com.
Register an endpoint
curl -X POST https://api.lite.shiftxpay.com/v1/merchants/me/webhooks \
-H "Authorization: Bearer $SHIFTXPAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example.com/hooks/shiftxpay",
"description": "Order fulfilment",
"event_types": ["payment.succeeded", "payment.refunded"],
"environment": "test"
}'| Field | Required | Notes |
|---|---|---|
url | yes | Must be https. Validated against SSRF (no loopback, private, or link-local targets in production). |
event_types | yes | At least one. See the event catalog. |
description | no | Free-text label shown in your dashboard. |
environment | no | Scopes the endpoint to a single environment (e.g. test). |
The response is the created endpoint plus a signing_secret that is returned
once and never again:
{
"id": "whk_8s1c...",
"url": "https://shop.example.com/hooks/shiftxpay",
"description": "Order fulfilment",
"event_types": ["payment.succeeded", "payment.refunded"],
"environment": "test",
"status": "active",
"signing_secret": "whsec_b1f3...shown-once"
}Store the signing_secret in your secrets manager immediately. If you lose it,
delete the endpoint and create a new one.
Manage endpoints
GET /v1/merchants/me/webhooks # list your endpoints
PATCH /v1/merchants/me/webhooks/{id} # { "status": "active" | "disabled" }
GET /v1/merchants/me/webhooks/{id}/deliveries # recent delivery attempts
POST /v1/merchants/me/webhooks/{id}/deliveries/{delivery_id}/retry
POST /v1/merchants/me/webhooks/{id}/test # emit a synthetic test.pingDisabling an endpoint (PATCH … { "status": "disabled" }) stops new deliveries
without deleting its history. Re-enable with "status": "active".
The test endpoint emits a synthetic test.ping event to one of your
endpoints so you can confirm the URL is reachable and your signature check
works before any real money moves.
Deliveries log
GET /v1/merchants/me/webhooks/{id}/deliveries returns recent attempts:
{
"data": [
{
"id": "5e3a...",
"event_type": "payment.succeeded",
"attempts": 2,
"delivered": true,
"failed": false,
"status_code": 200,
"created_at": "2026-06-27T09:14:02Z",
"payload": { "type": "payment.succeeded", "payment_id": "pay_3kP...", "status": "succeeded" }
}
]
}A delivery that has neither delivered nor failed set is still pending and
carries a next_attempt_at; a delivery that gave up carries failed: true and
a last_error. Re-queue any delivery with the …/deliveries/{delivery_id}/retry
endpoint — it resets the schedule so the dispatcher picks it up on its next tick.
Verify the signature
Every delivery carries two headers:
| Header | Value |
|---|---|
ShiftxPay-Event | The event type, e.g. payment.succeeded. |
ShiftxPay-Signature | t=<unix-seconds>,v1=<hex> |
The v1 value is HMAC-SHA256, keyed by your endpoint's signing_secret, over
the string <t> + "." + <raw-request-body>. In other words the timestamp and
the exact bytes of the request body are signed together, so a verifier must hash
the raw body — not a re-serialized copy.
Source:
gateway/internal/webhooks/dispatcher.go— thesignaturehelper computes"t=" + ts + ",v1=" + hex(hmac_sha256(secret, ts + "." + body))and sets it on theShiftxPay-Signatureheader.
Node (Express)
import crypto from "node:crypto";
const SIGNING_SECRET = process.env.SHIFTXPAY_WEBHOOK_SECRET;
// Mount with the raw body so the bytes are unchanged: app.post(path, express.raw({ type: "application/json" }), handler)
function verify(req) {
const header = req.get("ShiftxPay-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const { t, v1 } = parts;
if (!t || !v1) return false;
const signed = `${t}.${req.body}`; // req.body is a Buffer (raw bytes)
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(signed)
.digest("hex");
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/hooks/shiftxpay", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req)) return res.status(400).send("bad signature");
// Optional: reject stale timestamps to blunt replay attacks.
const t = Number(req.get("ShiftxPay-Signature").match(/t=(\d+)/)[1]);
if (Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).send("stale");
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event, idempotently ...
res.sendStatus(200); // any 2xx acknowledges the delivery
});Go
func verify(sigHeader string, body, secret []byte) bool {
var ts, v1 string
for _, p := range strings.Split(sigHeader, ",") {
k, val, _ := strings.Cut(p, "=")
switch k {
case "t":
ts = val
case "v1":
v1 = val
}
}
if ts == "" || v1 == "" {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts))
mac.Write([]byte("."))
mac.Write(body)
expected := mac.Sum(nil)
got, err := hex.DecodeString(v1)
return err == nil && hmac.Equal(got, expected)
}The signing timestamp t is set at send time. ShiftxPay does not enforce a
freshness window itself, so if you want replay protection, reject deliveries
whose t is older than a tolerance you choose (5 minutes is typical).
Hardening checklist
- HTTPS only. Plain
httpendpoints are rejected at registration. ShiftxPay re-checks the resolved IP at connect time, so a hostname that later resolves to a private address (DNS rebinding) is refused. - Verify every request before trusting the body. Reject on a missing or
mismatched
ShiftxPay-Signature. - Acknowledge fast, work later. Return
2xxas soon as you have persisted the event; do slow work (fulfilment, email) out of band. A non-2xxresponse is treated as a failure and retried. - Be idempotent. Delivery is at-least-once — see Delivery & idempotency.