Accept recurring payments with Safepay Subscriptions using a hosted checkout flow.
Safepay Subscriptions enable recurring billing (daily, weekly, monthly, yearly). The customer must approve billing through Safepay Checkout — subscriptions cannot be created silently via API.
Subscriptions are event-driven. Your system's source of truth must be webhook events — not redirect responses, not API polling.
When a customer chooses a subscription plan on your website, you redirect them to Safepay Checkout. After they authorize payment, Safepay sends webhooks to your server. You activate the subscription in your database only after a successful payment webhook.
subscription.created webhook to your endpoint.subscription.payment.succeeded webhook when the first charge succeeds.| Step | Action | Who |
|---|---|---|
| 1 | Create a subscription plan | Your backend |
| 2 | Generate auth token | Your backend |
| 3 | Build checkout URL + redirect customer | Your backend |
| 4 | Customer completes payment authorization | Safepay Checkout |
| 5 | subscription.created webhook fires | Safepay → Your webhook |
| 6 | subscription.payment.succeeded webhook fires | Safepay → Your webhook |
| 7 | Activate subscription in your DB | Your webhook handler |
Before you begin to integrate, make sure you have:
Warning: Sandbox and Live have different Secret Keys and different HMAC keys. Never mix environments.
After you have created your test account:
reference and Safepay sub_id.1
We provide official server-side API libraries for Node.js and PHP that allow you to interact with the Safepay API easily.
# Install the API librarynpm install --save @sfpy/node-core2
Create a plan once via the Safepay Dashboard or API. Plans are reusable — you do not recreate a plan per customer. Save the returned planId (token) for all subsequent checkout generation.
You can also create plans in the Merchant Dashboard under Subscriptions. Prefer the Dashboard or SDK for plan creation.
const Safepay = require('@sfpy/node-core');
const safepay = new Safepay(process.env.SAFEPAY_SECRET_KEY, {
authType: 'secret',
host: process.env.SAFEPAY_HOST, // Use env var — never hardcode
});
async function createPlan() {
try {
const response = await safepay.client.plans.create({
payload: {
amount: '5000', // In lowest currency unit (paisas for PKR)
currency: 'PKR', // PKR | USD
interval: 'MONTH', // DAY | WEEK | MONTH | YEAR
interval_count: 1, // Every 1 month
billing_cycles: 0, // 0 = indefinite; N = fixed cycles
type: 'RECURRING',
product: 'premium-plan', // Unique identifier for your product
active: true
}
});
console.log('Plan created:', response.data.token); // Save this planId
return response.data.token;
} catch (err) {
console.error('Plan creation failed:', err.message);
throw err;
}
}
Example response (sandbox placeholder):
{
"data": {
"token": "plan_33e626b3-d92e-40b3-a379-4f89d61f8c83",
"amount": "5000",
"currency": "PKR",
"interval": "MONTH",
"interval_count": 1,
"billing_cycles": 0,
"type": "RECURRING",
"product": "premium-plan",
"active": true
},
"status": {
"errors": [],
"message": "success"
}
}
Note down the
"token": "plan_...". This is yourplan_idfor checkout URL generation. Plan creation is recommended via the Merchant Dashboard or the official SDK (safepay.client.plans.create). Prefer those over hand-rolled REST calls so you stay aligned with the client library.
If you create the plan in the Dashboard:
plan_...) into your environment or database.| Parameter | Required | Description |
|---|---|---|
amount | Yes | Price in lowest currency unit (paisas for PKR), as a string |
currency | Yes | PKR or USD |
interval | Yes | DAY | WEEK | MONTH | YEAR |
interval_count | Yes | Multiplier — e.g. 2 + WEEK = every 2 weeks |
billing_cycles | Yes | 0 = indefinite; N = stops after N charges |
type | Yes | Always RECURRING for subscriptions |
product | Yes | Your unique product identifier |
active | Yes | Set true to make the plan available for checkout |
billing_cycles: 0means the subscription renews indefinitely until cancelled. Set a non-zero value for fixed-term plans (e.g. an annual plan with12billing cycles for monthly charges).
3
After you have a plan, make a POST request to /client/passport/v1/token to generate a short-lived authentication token. Tokens expire in about 1 hour. Cache with a buffer to avoid unnecessary API calls at scale.
const axios = require('axios');
let tokenCache = { value: null, expiresAt: 0 };
async function getAuthToken() {
const now = Date.now();
// Refresh if expired or within 5-minute buffer
if (tokenCache.value && now < tokenCache.expiresAt - 300000) {
return tokenCache.value;
}
const response = await axios.post(
`${process.env.SAFEPAY_HOST}/client/passport/v1/token`,
{},
{
headers: {
Authorization: `Bearer ${process.env.SAFEPAY_SECRET_KEY}`
}
}
);
tokenCache = {
value: response.data.token,
expiresAt: now + 3600000 // 1 hour TTL
};
return tokenCache.value;
}
Example response (sandbox placeholder):
{
"token": "xnTyRgITVcHlyeKT2cf59_e836PouieQ6xPpuQiwFXD8M6HoJ283EP_zta2SKkm6B_IFNGEBmg=="
}
Warning: For multi-instance deployments (multiple servers/pods), store the token cache in Redis rather than in-process memory to avoid redundant token generation.
4
Now that the plan and authentication token are available, your server must build the Checkout URL and redirect the customer so they can authorize the subscription.
Build the URL on your server against SAFEPAY_CHECKOUT_HOST with query parameters: plan_id, reference, redirect_url, cancel_url, and auth_token.
function buildCheckoutUrl({ planId, reference, token, redirectUrl, cancelUrl }) {
const base = process.env.SAFEPAY_CHECKOUT_HOST;
const params = new URLSearchParams({
plan_id: planId,
reference: reference, // YOUR internal ID — this is your correlation key
redirect_url: redirectUrl,
cancel_url: cancelUrl,
auth_token: token,
});
return `${base}/checkout?${params.toString()}`;
}
cURL is not used to open checkout in the browser. After you obtain an auth token, assemble the URL and redirect the shopper:
https://sandbox.getsafepay.com/checkout?plan_id=plan_...&reference=sub_user_123&redirect_url=https%3A%2F%2Fmywebsite.com%2Fsubscribe%2Fsuccess&cancel_url=https%3A%2F%2Fmywebsite.com%2Fsubscribe%2Fcancel&auth_token=...
Live checkout host: https://getsafepay.com.
Persist your reference before redirecting. If a webhook arrives before your success page runs, the row must already exist.
app.get('/subscribe', async (req, res) => {
const { userId, planId } = req.query;
// Generate a stable, idempotent reference you can store and look up
const reference = `sub_${userId}_${Date.now()}`;
// Persist reference → pending state BEFORE redirect
await db.subscriptions.create({
reference,
user_id: userId,
plan_id: planId,
status: 'pending',
created_at: new Date(),
});
const token = await getAuthToken();
const url = buildCheckoutUrl({
planId,
reference,
token,
redirectUrl: `${process.env.APP_URL}/subscribe/success`,
cancelUrl: `${process.env.APP_URL}/subscribe/cancel`,
});
res.redirect(url);
});
Warning: Always persist the
referencebefore redirect. If a webhook arrives before your redirect handler runs, you need the row to already exist in your DB.
| Signal | Reliable? | Use for |
|---|---|---|
redirect_url callback | NO — can be bypassed, duplicated, or missed | UI feedback only |
cancel_url callback | NO — same caveats as above | UI feedback only |
subscription.payment.succeeded webhook | YES | Activate subscription |
subscription.created webhook | YES | Store sub_id |
Critical: Never activate a subscription based on a redirect URL callback. Only webhook events are authoritative.
5
Webhooks are the only reliable mechanism for subscription state changes. Build webhook handling before testing end-to-end flows.
This sequence is non-negotiable. Deviating from it causes race conditions and missed activations.
Warning: ACK (HTTP 200) before business logic. If your business logic throws, Safepay will not retry if you already responded 200. Log failures internally and handle via your own retry mechanism.
Every webhook includes an HMAC-SHA256 signature. Verify it before processing any event. Use the raw request body (not parsed JSON).
const crypto = require('crypto');
function verifyHmacSignature(secret, rawBody, receivedSignature) {
const computed = crypto
.createHmac('sha256', secret)
.update(rawBody) // Use RAW buffer — not parsed JSON
.digest('hex');
// timingSafeEqual prevents timing-based attacks
return crypto.timingSafeEqual(
Buffer.from(computed, 'hex'),
Buffer.from(receivedSignature, 'hex')
);
}
// In Express: capture raw body BEFORE json() middleware
app.use('/webhook/safepay', express.raw({ type: 'application/json' }));
Conceptual (any language):
X-SFPY-SIGNATURE header.HMAC-SHA256(secret, raw_body) as hex.timingSafeEqual / hash_equals).Critical: Use
timingSafeEqual(Node) orhash_equals(PHP) — not===. Plain string comparison is vulnerable to timing attacks that can allow signature forgery.
app.post(
'/webhook/safepay',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-sfpy-signature'];
const rawBody = req.body; // Buffer — required for HMAC
// 1. Verify signature
if (!verifyHmacSignature(process.env.SAFEPAY_WEBHOOK_SECRET, rawBody, signature)) {
console.warn('Invalid HMAC — rejecting webhook');
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
// 2. Deduplicate
const alreadyProcessed = await db.webhookEvents.exists({ event_id: event.id });
if (alreadyProcessed) {
return res.status(200).send('Duplicate — already processed');
}
// 3. Persist raw event
await db.webhookEvents.insert({
event_id: event.id,
type: event.type,
payload: JSON.stringify(event),
received_at: new Date(),
processed: false,
});
// 4. ACK immediately
res.status(200).send('OK');
// 5. Process asynchronously
processWebhookEvent(event).catch((err) => {
console.error(`Webhook processing failed [${event.id}]:`, err);
// Alert your team — do NOT re-throw here
});
}
);
Handle subscription events by type. Use reference as your primary lookup key.
async function processWebhookEvent(event) {
switch (event.type) {
case 'subscription.created':
// Store mapping: reference -> sub_id
await db.subscriptions.upsert({
reference: event.data.reference,
sub_id: event.data.subscription_id,
status: 'pending',
plan_id: event.data.plan_id,
});
break;
case 'subscription.payment.succeeded':
// Primary activation event — use reference to correlate
await db.subscriptions.updateByReference(event.data.reference, {
status: 'active',
last_txn_id: event.data.transaction_id,
activated_at: new Date(),
});
await grantUserAccess(event.data.reference);
break;
case 'subscription.payment.failed':
await db.subscriptions.updateByReference(event.data.reference, {
status: 'payment_failed',
});
await notifyUserPaymentFailed(event.data.reference);
break;
case 'subscription.cancelled':
await db.subscriptions.updateByReference(event.data.reference, {
status: 'cancelled',
cancelled_at: new Date(),
});
await revokeUserAccess(event.data.reference);
break;
case 'payment.succeeded':
// For subscription flows: NO-OP
// This event carries no reference — use subscription.payment.succeeded instead
// Only handle here if you also process standalone one-time payments
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
await db.webhookEvents.markProcessed(event.id);
}
| Event | Has reference? | Has sub_id? | Action required |
|---|---|---|---|
subscription.created | YES | YES | Store reference + sub_id, status = pending |
subscription.payment.succeeded | YES | YES | Activate subscription, grant access |
subscription.payment.failed | YES | YES | Mark failed, notify user |
subscription.cancelled | YES | YES | Revoke access, update status |
payment.succeeded | NO | NO | NO-OP in subscription flows |
Event payloads carry the identifiers listed above (reference, subscription_id / sub_id, and on payment success transaction_id). Do not invent additional webhook JSON shapes beyond these fields and event types.
6
Understanding which identifiers appear on which events is critical to building a correct integration.
| Identifier | What it is | Present on |
|---|---|---|
reference | YOUR internal ID — the primary correlation key | subscription.created, subscription.payment.* |
sub_id (sub_...) | Safepay subscription object ID | subscription.created, subscription.payment.* |
transaction_id (txn_...) | Individual payment transaction ID | subscription.payment.succeeded |
tracker (track_...) | Low-level payment tracker token | payment.succeeded ONLY |
// Correct correlation architecture:
//
// subscription.created → reference (yours) + sub_id (Safepay's)
// subscription.payment.* → reference (yours) — use this to look up your record
//
// WRONG: trying to link track_... from payment.succeeded to sub_...
// CORRECT: ignore payment.succeeded in subscription flows entirely
// Store both identifiers on subscription.created:
await db.subscriptions.upsert({
reference: event.data.reference, // Primary key for your lookups
sub_id: event.data.subscription_id, // Use for Safepay API calls (cancel, etc.)
status: 'pending'
});
// All subsequent events: look up by reference
const subscription = await db.subscriptions.findByReference(event.data.reference);
Critical:
payment.succeededfires for ALL payments on your account — including one-time payments. It has no subscription context. Do not attempt to correlate it with subscription events.
7
# .env.sandbox
SAFEPAY_HOST=https://sandbox.api.getsafepay.com
SAFEPAY_CHECKOUT_HOST=https://sandbox.getsafepay.com
SAFEPAY_SECRET_KEY=your_sandbox_secret_key
SAFEPAY_WEBHOOK_SECRET=your_sandbox_hmac_key
# .env.production
SAFEPAY_HOST=https://api.getsafepay.com
SAFEPAY_CHECKOUT_HOST=https://getsafepay.com
SAFEPAY_SECRET_KEY=your_live_secret_key
SAFEPAY_WEBHOOK_SECRET=your_live_hmac_key # DIFFERENT from sandbox
| Host | Sandbox | Live |
|---|---|---|
| API | https://sandbox.api.getsafepay.com | https://api.getsafepay.com |
| Checkout | https://sandbox.getsafepay.com | https://getsafepay.com |
Critical: The HMAC webhook secret is DIFFERENT between Sandbox and Live dashboards. Verify you are using the correct key for each environment before going live.
| Resource | Sandbox | Live |
|---|---|---|
| Dashboard | sandbox.api.getsafepay.com/dashboard | getsafepay.com/dashboard |
| HMAC Key | Sandbox Dashboard → Developers → Endpoints | Live Dashboard → Developers → Endpoints |
| Webhook Setup | Sandbox Dashboard → Developers → Endpoints | Live Dashboard → Developers → Endpoints |
| Test Webhook API | Available | N/A |
8
subscription.createdsubscription.payment.succeededsubscription.payment.failedsubscription.cancelledWarning: Do not subscribe to
payment.succeededfor subscription-only integrations. It carries no subscription context and will cause correlation confusion.
| Requirement | Test environment | Live environment |
|---|---|---|
| Protocol | HTTP or HTTPS | HTTPS only |
| TLS version | TLSv1.2 or TLSv1.3 | TLSv1.2 or TLSv1.3 |
| Allowed ports (HTTP) | 80, 8080, 8888 | Not allowed |
| Allowed ports (HTTPS) | 443, 8443, 8843 | 443, 8443, 8843 |
| Response timeout | 10 seconds | 10 seconds |
| Expected response | HTTP 200 | HTTP 200 |
| Retry behavior | Failed events go to retry queue | Failed events go to retry queue |
| Status | Meaning | Triggered by |
|---|---|---|
pending | Checkout initiated, no payment yet | Your system on checkout creation |
active | Billing ongoing, access granted | subscription.payment.succeeded |
payment_failed | Latest charge failed | subscription.payment.failed |
cancelled | Subscription stopped | subscription.cancelled |
Always derive status from the latest webhook event. Do not infer status from API polling or redirect callbacks.
9
Before going live, complete every item below. Test end-to-end in Sandbox first, then switch to live credentials.
timingSafeEqual / hash_equalspayment.succeeded is a NO-OP in the subscription flowsubscription.* events handled: created, payment.succeeded, payment.failed, cancelledreference stored on subscription creation (before redirect)sub_id stored from subscription.created webhookreference from webhook eventsWhen you are ready to go live:
planId — verify the plan exists in the correct environment.payment.succeeded has no reference — this is expected; ignore it in subscription flows.track_... cannot be mapped to sub_... in webhook payloads — use reference as your key.subscription.payment.succeeded (not payment.succeeded) to trigger activation.event_id — store event IDs and check before processing.processWebhookEvent is idempotent.For integration support, contact Safepay Support.
Source: Safepay Subscriptions Integration Guide — Version 2.0 | May 2026