ShiftxPay
Webhooks

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"
  }'
FieldRequiredNotes
urlyesMust be https. Validated against SSRF (no loopback, private, or link-local targets in production).
event_typesyesAt least one. See the event catalog.
descriptionnoFree-text label shown in your dashboard.
environmentnoScopes 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.ping

Disabling 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:

HeaderValue
ShiftxPay-EventThe event type, e.g. payment.succeeded.
ShiftxPay-Signaturet=<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 — the signature helper computes "t=" + ts + ",v1=" + hex(hmac_sha256(secret, ts + "." + body)) and sets it on the ShiftxPay-Signature header.

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 http endpoints 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 2xx as soon as you have persisted the event; do slow work (fulfilment, email) out of band. A non-2xx response is treated as a failure and retried.
  • Be idempotent. Delivery is at-least-once — see Delivery & idempotency.

On this page