Reference
Webhooks
The signature-verified payment-gateway webhook: verification & retries.
GrivaOne integrates with an online payment gateway for subscription payments. The gateway notifies GrivaOne of payment outcomes via a single, signature-verified webhook. This is an inbound callback — your application does not call it, and it is the only place a payment is ever marked paid.
Payment-gateway endpoints
Non-secret gateway config: whether it is configured, the publishable key and the webhook path to register.
Start a hosted-checkout payment (OWNER/ADMIN); returns the URL to redirect to. 412 if the gateway is not configured.
Poll a checkout’s status after the provider redirects back. Reflects the webhook truth, not the browser.
Inbound provider callback — signature-verified. This is the only place a payment is marked paid.
The checkout flow
- Check whether the gateway is live for the tenant with
GET /payments-gateway/config. It returns aconfiguredflag and the public publishable key — never the secret key or webhook secret. - Start a payment with
POST /payments-gateway/checkoutand redirect the customer to the hosted-checkout URL it returns. If the gateway is not configured this responds412 Precondition Failed. - After the provider redirects back, confirm the outcome with
GET /payments-gateway/checkout/:id. Itsstatusreflects the verified webhook — not what the browser reports.
curl https://gulfpay-production.up.railway.app/api/v1/payments-gateway/config \
-H "Authorization: Bearer <ACCESS_TOKEN>"curl -X POST https://gulfpay-production.up.railway.app/api/v1/payments-gateway/checkout \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ "organizationId": "org_123", "planId": "pro_monthly" }'How the webhook is secured
The provider sends a server-to-server POST to /payments-gateway/webhook. Because providers cannot carry a GrivaOne bearer token, the endpoint is public — but every delivery is verified before anything is trusted:
- The shared secret / signature is checked against the raw request bytes exactly as the provider sent them. An unsigned or forged delivery is rejected with
403 Forbidden. - Delivery is idempotent by provider event id, so a retried or duplicated event is a safe no-op.
- A payment is recorded only on a genuine
paidevent. A verified event (including a duplicate) returns200so the provider stops retrying.
Verifying a signature
Signature verification always runs against the raw request body — the exact bytes the provider sent. Compute the HMAC over those bytes with the shared secret and compare it to the signature header using a constant-time comparison. GrivaOne does this internally; the same pattern applies if you ever receive provider callbacks directly.
// Illustrative HMAC verification of a provider callback.
// GrivaOne performs this server-side; the shape mirrors the pattern you would
// use if you receive provider callbacks of your own. Verify against the RAW,
// unparsed request bytes — parsing then re-serialising changes them.
import crypto from 'node:crypto'
function isGenuine(rawBody /* Buffer */, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // raw bytes, exactly as received
.digest('hex')
// Constant-time compare avoids leaking the secret via timing.
const a = Buffer.from(expected)
const b = Buffer.from(signatureHeader || '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Never parse before you verify
403.Retries & idempotency
Providers retry a webhook until they receive a success response, so the same event can arrive more than once. Two rules keep that safe:
- Acknowledge fast. Return
2xxas soon as the event is verified and accepted; do heavy work afterwards. A slow handler looks like a failure and triggers more retries. - Deduplicate by event id.Delivery is idempotent on the provider's event id, so a replayed event is a no-op — the payment is recorded exactly once regardless of how many times it is delivered.
Because the browser redirect is not authoritative, always confirm the outcome server-side with GET /payments-gateway/checkout/:id, which reflects the verified webhook rather than what the browser reported.
No outbound webhooks yet