La documentation technique est en anglais, pour vous et pour votre agent IA.

Webhooks

A webhook is an HTTPS POST we send to your server when something happens: a payment succeeds, a payout is paid… Add endpoints in Dashboard → Webhooks (test and live have separate endpoints).

Events

Type When
payment.succeeded The money was received. Fulfil the order.
payment.failed The payment did not go through.
payment.requires_review We are checking the payment with the provider. Wait.
checkout_session.completed A hosted checkout page was paid.
payout.paid A payout reached the phone.
payout.failed A payout failed; the amount is back in your balance.
ping Sent by the "Send test" button.

Payload

{
  "id": "evt_8Qm2pXc4Lw0sVb7nTr5yKa9d",
  "object": "event",
  "type": "payment.succeeded",
  "livemode": false,
  "created_at": "2026-11-02T10:15:04.112Z",
  "data": {
    "object": { "id": "pay_3hX9…", "object": "payment", "status": "succeeded", "amount": 5000, "net": 4862, "reference": "order-1042", "…": "…" }
  }
}

data.object is the same object as GET /v1/payments/{id} returns.

Verifying the signature

Every request carries:

Patronise-Signature: t=1767312000,v1=5f2c9a…e91a
Patronise-Event-Id: evt_8Qm2pXc4Lw0sVb7nTr5yKa9d

v1 is the hex HMAC-SHA256 of "<t>.<raw request body>", keyed with your endpoint's signing secret (whsec_…). To verify:

  1. Read the raw body before any JSON parsing.
  2. Split the header; take t and v1.
  3. Reject if t is more than 5 minutes away from now.
  4. Compute HMAC_SHA256(secret, t + "." + rawBody) and compare it to v1 in constant time.

Node.js (Express)

import crypto from 'node:crypto'
import express from 'express'

const app = express()

app.post('/webhooks/patronise', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('Patronise-Signature') || ''
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const expected = crypto
    .createHmac('sha256', process.env.PATRONISE_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`)
    .digest('hex')
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
  const valid = fresh && parts.v1 && expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
  if (!valid) return res.status(400).send('bad signature')

  const event = JSON.parse(req.body)
  if (event.type === 'payment.succeeded') {
    // markOrderPaid(event.data.object.reference) — make it idempotent
  }
  res.sendStatus(200)
})

PHP

$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_PATRONISE_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $raw, getenv('PATRONISE_WEBHOOK_SECRET'));
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
    http_response_code(400);
    exit('bad signature');
}
$event = json_decode($raw, true);
// if ($event['type'] === 'payment.succeeded') { … }
http_response_code(200);

Python (Flask)

import hmac, hashlib, os, time
from flask import request, abort

@app.post("/webhooks/patronise")
def patronise_webhook():
    raw = request.get_data()
    sig = dict(p.split("=", 1) for p in request.headers.get("Patronise-Signature", "").split(","))
    expected = hmac.new(os.environ["PATRONISE_WEBHOOK_SECRET"].encode(),
                        f"{sig.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
    if abs(time.time() - int(sig.get("t", 0))) > 300 or not hmac.compare_digest(expected, sig.get("v1", "")):
        abort(400)
    event = request.get_json()
    return "", 200

Delivery rules

  • Answer with any 2xx within 10 seconds. Do slow work after answering.
  • If you answer anything else, or time out, we retry with growing delays (30 s, 1 min, 2 min… up to 6 h between tries) for about 24 hours.
  • The same event can arrive more than once, and events can arrive out of order. Use id to skip duplicates, and trust the status in data.object.
  • Every delivery is listed in Dashboard → Webhooks → History, with the HTTP code your server returned and a Resend button.
  • To test locally, expose your machine with cloudflared tunnel --url http://localhost:3000 or ngrok http 3000.

Besoin d’aide pour l’intégrer ?