Webhooks
When a payment reaches a terminal status, Cartino Pay POSTs a JSON event to your application's webhook URL. This is the recommended way to learn about payment outcomes.
Events
| Event | Sent when |
|---|---|
payment.paid |
Verification with the gateway confirmed the payment |
payment.failed |
The gateway reported the payment as not paid / refused |
payment.cancelled |
The payer cancelled at the gateway |
payment.expired |
The payer did not complete the payment before expires_at |
Each payment produces exactly one terminal event (plus retries of that same event).
Request
POST /payments/webhook HTTP/1.1
Host: cartino.cloud
Content-Type: application/json
User-Agent: CartinoPay-Webhook/1.0
X-CartinoPay-Event: payment.paid
X-CartinoPay-Event-Id: 7f3c9a2e-4b1d-4f7a-9c2e-1a2b3c4d5e6f
X-CartinoPay-Timestamp: 1757836512
X-CartinoPay-Delivery-Attempt: 1
X-CartinoPay-Signature: t=1757836512,v1=5f4dcc3b5aa765d61d8327deb882cf99…
{
"event": "payment.paid",
"event_id": "7f3c9a2e-4b1d-4f7a-9c2e-1a2b3c4d5e6f",
"created_at": "2026-09-14T10:05:12+03:30",
"payment_id": "PAY_01J9X4Z8K3M2N5P7Q9R1S3T5V7",
"application": "cartino-cloud",
"order_id": "CLOUD-12345",
"status": "paid",
"amount": 500000,
"currency": "IRR",
"tracking_code": "123456789",
"card_pan_masked": "603799******1234",
"paid_at": "2026-09-14T10:05:10+03:30",
"failure_code": null,
"failure_reason": null,
"test": false,
"metadata": { "user_id": 123, "plan": "pro" }
}
Verifying the signature
The signature is an HMAC-SHA256 over "<timestamp>.<raw request body>" using your webhook secret (whsec_…):
X-CartinoPay-Signature: t=<unix timestamp>,v1=<hex digest>
Steps:
- Read the raw body (before any JSON parsing).
- Parse the header into
tand one or morev1values. - Reject if
|now − t| > 300seconds (replay protection). - Compute
hex(HMAC_SHA256(secret, t + "." + rawBody)). - Compare with constant-time comparison against each
v1. Accept if any matches (there may be two during a secret rotation).
PHP / Laravel
public function handle(Request $request)
{
$secret = config('services.cartino_pay.webhook_secret');
$header = $request->header('X-CartinoPay-Signature', '');
$body = $request->getContent();
if (! $this->verifySignature($secret, $body, $header)) {
return response('invalid signature', 400);
}
$event = $request->json()->all();
// De-duplicate: retries carry the same event_id.
if (WebhookEvent::where('event_id', $event['event_id'])->exists()) {
return response('', 200);
}
WebhookEvent::create(['event_id' => $event['event_id']]);
if ($event['event'] === 'payment.paid') {
Order::where('cartino_payment_id', $event['payment_id'])->firstOrFail()->markPaid($event['tracking_code']);
}
return response('', 200);
}
private function verifySignature(string $secret, string $body, string $header, int $tolerance = 300): bool
{
$timestamp = null; $signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k === 't' && ctype_digit((string) $v)) $timestamp = (int) $v;
if ($k === 'v1' && $v !== null) $signatures[] = $v;
}
if ($timestamp === null || ! $signatures || abs(time() - $timestamp) > $tolerance) return false;
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) return true;
}
return false;
}
Make sure the route is excluded from CSRF protection ($middleware->validateCsrfTokens(except: ['payments/webhook']) in bootstrap/app.php).
Node.js / Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/payments/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.CARTINO_PAY_WEBHOOK_SECRET;
const header = req.get('X-CartinoPay-Signature') ?? '';
if (!verifySignature(secret, req.body, header)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// TODO: de-duplicate on event.event_id, then fulfil the order
if (event.event === 'payment.paid') {
// markOrderPaid(event.payment_id, event.tracking_code)
}
res.sendStatus(200);
});
function verifySignature(secret, rawBody, header, tolerance = 300) {
let timestamp = null; const signatures = [];
for (const part of header.split(',')) {
const [k, v] = part.trim().split('=');
if (k === 't' && /^\d+$/.test(v)) timestamp = Number(v);
if (k === 'v1' && v) signatures.push(v);
}
if (timestamp === null || !signatures.length) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return signatures.some((sig) => sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}
Responding
- Return any 2xx within 10 seconds. Do slow work (emails, provisioning) asynchronously.
- Any other status, a timeout, or a connection error counts as a failed attempt.
- Redirects are not followed.
Retries
Failed deliveries are retried with exponential backoff:
| Attempt | Delay after previous |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After 6 attempts the delivery is marked exhausted; admins can see the failure (HTTP status, body, error) and re-trigger it manually. Because of retries your handler must be idempotent — de-duplicate on event_id (or on payment_id + event).
Security checklist
- ✅ Verify the signature on every request — this is the only proof the request came from Cartino Pay.
- ✅ Enforce the timestamp tolerance.
- ✅ De-duplicate on
event_id. - ✅ Serve the webhook URL over HTTPS.
- ✅ Treat the payload as data; re-read authoritative values from your own order if in doubt (
amount,currency). - ❌ Do not whitelist by source IP alone; use the signature.