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:

  1. Read the raw body (before any JSON parsing).
  2. Parse the header into t and one or more v1 values.
  3. Reject if |now − t| > 300 seconds (replay protection).
  4. Compute hex(HMAC_SHA256(secret, t + "." + rawBody)).
  5. 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

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