Registering your webhook
Register
In the PayGate app portal, open your app’s settings page (/apps/{id}) and fill in Webhook URL — an HTTPS endpoint on your own server that accepts POST requests. Save.
Then click Generate webhook secret (or Regenerate webhook secret if you already have one). The plaintext secret is shown exactly once — copy it now. PayGate stores only an encrypted copy; if you lose the plaintext, regenerate it (this invalidates the previous secret immediately).
There’s no separate “enable” step — as soon as webhook_url is set and a secret exists, deliveries start going out on the next qualifying event.
Delivery format
POST to your webhook_url, Content-Type: application/json:
{
"event_type": "subscription.updated",
"app_id": 2,
"data": {
"external_ref": "user-123",
"paygate_customer_id": "cus_5d2e7b9a1c4f8036ab12de45",
"merchant_customer_id": "crm-1001",
"plan_id": "plan_3f9c1a7e5b2d4c8a91e0f6b2",
"price_id": "price_8a1d6c3e9f024b7d5a2c1e90",
"plan_ref": "premium",
"status": "active",
"current_period_end": "2026-10-15 05:55:56",
"in_trial": false,
"trial_ends_at": null
},
"created_at": "2026-09-15T05:55:56+00:00"
}
The envelope (event_type, app_id, data, created_at) is the same for every event; only data differs. The events are listed in 04 — Webhooks.
Headers:
| Header | Meaning |
|---|---|
X-PayGate-Timestamp | Unix timestamp (seconds) when the request was signed |
X-PayGate-Signature | hex(HMAC_SHA256("{timestamp}.{raw_body}", your_webhook_secret)) |
Payload examples
Only the data object is shown; it sits in the envelope above.
subscription.renewed:
{
"external_ref": "user-123",
"paygate_customer_id": "cus_5d2e7b9a1c4f8036ab12de45",
"merchant_customer_id": "crm-1001",
"plan_id": "plan_3f9c1a7e5b2d4c8a91e0f6b2",
"price_id": "price_8a1d6c3e9f024b7d5a2c1e90",
"plan_ref": "premium",
"provider_code": "payos",
"transaction_id": 57,
"amount": 199000,
"currency": "VND",
"previous_period_end": "2026-10-15 05:55:56",
"current_period_end": "2026-11-15 05:55:56",
"was_trial": false
}
was_trial is true when the payment converted a trial into a paid period.
subscription.renewal_reminder_sent:
{
"external_ref": "user-123",
"paygate_customer_id": "cus_5d2e7b9a1c4f8036ab12de45",
"merchant_customer_id": "crm-1001",
"plan_id": "plan_3f9c1a7e5b2d4c8a91e0f6b2",
"price_id": "price_8a1d6c3e9f024b7d5a2c1e90",
"plan_ref": "premium",
"reminder_day": 3,
"period_end": "2026-10-15 05:55:56",
"in_trial": false,
"sent_at": "2026-10-12T05:55:56+00:00"
}
reminder_day is 3, 1, or 0 for the “expired” notice.
transaction.completed (one-time mode: "payment" only):
{
"external_ref": "user-123",
"plan_ref": "lifetime_pass",
"amount": 199000,
"currency": "VND",
"status": "completed"
}
Verifying the signature
Recompute the signature over the exact raw request body (before any JSON re-encoding) and compare using a constant-time function:
$timestamp = $_SERVER['HTTP_X_PAYGATE_TIMESTAMP'];
$signature = $_SERVER['HTTP_X_PAYGATE_SIGNATURE'];
$rawBody = file_get_contents('php://input');
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $yourWebhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(400);
exit;
}
Reject requests where $timestamp is too far from the current time (e.g. more than 5 minutes) to guard against replay of an intercepted payload.
Responding
Return any 2xx status to acknowledge. Anything else (including no response / timeout) is treated as a failure and retried.
Retries
Failed deliveries are retried up to 5 times with increasing backoff (1, 5, 15, 60, 240 minutes). After the final attempt, the delivery is marked failed and not retried further — if you need to recover a missed event, poll GET /api/v1/subscriptions/{external_ref} (or GET /api/v1/transactions/{transaction_id} for a one-time payment) instead.
Webhook delivery is fire-and-forget relative to the underlying operation — a delivery failure never rolls back or blocks the payment/subscription change itself.