MagiaPay· Webhooks

Webhooks

MagiaPay POSTs signed JSON event envelopes to your registered endpoints. Every delivery carries an HMAC-SHA256 signature; verify it before trusting the payload.

Headers delivered

HeaderExampleMeaning
X-MagiaPay-Signaturet=1776840000,v1=a3f8…HMAC-SHA256, see below.
X-MagiaPay-Eventpayment.succeededEvent type for routing.
X-MagiaPay-Delivery42Numeric delivery attempt id.
User-AgentMagiaPay/1.0Lets you allowlist traffic.

Signature format

HMAC-SHA256 format
X-MagiaPay-Signature: t=<unix>,v1=<hex>

signedPayload = `${t}.${rawBody}`
signature     = hmacSha256(signingSecret, signedPayload)

Three gotchas:

  • Compute HMAC over the raw request body. Don't JSON-parse-then-re-serialise — whitespace differences will break the hash.
  • Reject any webhook where |now − t| > 300 seconds to prevent replay attacks.
  • Use constant-time equality (crypto.timingSafeEqual) to compare signatures.

Verify the signature

import crypto from 'node:crypto';

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Event envelope

POST /your-webhook
{
  "id":       "evt_2e599b29fc3203f06a63cc16",
  "object":   "event",
  "type":     "payment.succeeded",
  "environment": "live",
  "created":  1776840100,
  "data": {
    "object": {
      "id":          "pay_0e1f2a4c9b8d6a3f5c7e1d2b",
      "object":      "payment",
      "status":      "succeeded",
      "amount":      500.00,
      "currency":    "PHP",
      "method":      "gcash_qr",
      "provider":    "directpay",
      "fee_amount":  12.50,
      "net_amount":  487.50,
      "created":     1776839700
    }
  }
}

Retry schedule

Non-2xx responses trigger retries at 1m, 5m, 30m, 2h, 6h, 24h. After 6 failures the delivery is parked as failed. Replay manually from the dashboard (/dashboard/webhooks) or via GET /v1/events reconciliation.

Respond fast

Your handler should acknowledge in under 10 seconds. If heavy processing is needed, enqueue the payload to a background worker and return 200 immediately — otherwise the delivery will time out and retry.

See also

  • Events — the canonical log, for reconciling missed deliveries.
  • Errors — what to expect from the API when sending POST /v1/payments etc.