Webhooks#
Povito POSTs a signed JSON event to every endpoint you register whose events include the event's type. Two rules make webhooks safe to build on:
- Verify the signature on the raw bytes before you parse anything, with a constant-time compare and a five-minute timestamp window.
- Treat the event as a prompt, not a fact.
GET /v1/checkout/sessions/{id}and act on that. Povito itself never marks a payment on a vendor's callback without verifying with the vendor; hold your integration to the same standard.
Event types#
| Type | Fires when | data.object |
|---|---|---|
checkout.session.completed |
An attempt succeeded, or the shopper chose cash on delivery | the session (payment_status paid or requires_offline_collection) |
checkout.session.expired |
expires_at passed, or you called /expire |
the session |
checkout.session.canceled |
The shopper cancelled on the page | the session |
payment.attempt.failed |
An attempt failed, was declined, cancelled at the vendor or timed out; the session is back to open |
the session plus failed_attempt: { attempt_id, method, gateway, status, reason } |
refund.succeeded |
A refund settled | the refund |
refund.failed |
A refund failed or was rejected by the vendor | the refund |
customer.instrument.saved |
A card was tokenised for reuse (Phase 2) | the instrument — never the token |
customer.instrument.revoked |
A saved card was revoked (Phase 2) | the instrument |
Subscribe to ["*"] for everything. New types may be added inside v1; ignore what you do not recognise.
payment.attempt.failed is informational — the session is still open and the shopper is being offered another method. Do not cancel the order on it.
The delivery#
POST /webhooks/povito HTTP/1.1
Host: your-store.example
Content-Type: application/json
User-Agent: PovitoCheckout-Webhooks/1
Povito-Signature: t=1789041871,v1=5f1a9c0b7e2d4a6f8b1c3d5e7f9a0b2c4d6e8f0a1b3c5d7e9f1a3b5c7d9e1f3a
Povito-Event-Id: evt_test_4DEFGH1J3M8XQ2K7A9BC
Povito-Event-Type: checkout.session.completed
{"id":"evt_test_4DEFGH1J3M8XQ2K7A9BC","object":"event","type":"checkout.session.completed","livemode":false,"created_at":"2026-09-10T12:04:31.000Z","data":{"object":{"id":"cs_test_7KQ4M2XA9BC3DEFGH1JK","object":"checkout.session","status":"completed","payment_status":"paid",…}}}- Timeout 10 seconds. Respond
2xxfast and do the work afterwards; a3xxis not followed and counts as a failure. Povito-Event-IdandPovito-Event-Typeduplicateidandtypefrom the body so you can route and de-duplicate before parsing.- The body is compact JSON. Sign-check the bytes exactly as received; re-serialising will change whitespace and key order and break the signature.
Verifying the signature#
Povito-Signature is t=<unix seconds>,v1=<hex>, where
v1 = HMAC-SHA256( endpoint_secret, "<t>" + "." + <raw request body> )Reject the delivery if |now − t| > 300 seconds, or if no v1 entry equals your computed HMAC. During a secret rotation the header carries two v1 entries (old and new secret); accept if any one matches. Compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto"
export function verifyPovitoSignature(rawBody, header, secret, toleranceSeconds = 300, now = Math.floor(Date.now() / 1000)) {
if (!header) return false
const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8")
const parts = header.split(",").map((p) => p.trim().split("="))
const t = Number(parts.find((p) => p[0] === "t")?.[1])
if (!Number.isFinite(t) || Math.abs(now - t) > toleranceSeconds) return false
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"), "hex")
return parts
.filter((p) => p[0] === "v1")
.some((p) => {
const given = Buffer.from(p[1] ?? "", "hex")
return given.length === expected.length && timingSafeEqual(given, expected)
})
}
// Express: keep the raw body — express.json() would re-serialise it.
app.post("/webhooks/povito", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyPovitoSignature(req.body, req.header("povito-signature"), process.env.POVITO_WEBHOOK_SECRET)) return res.status(401).end()
const event = JSON.parse(req.body.toString("utf8"))
queue.push(event) // ack now, process later
res.status(200).end()
})<?php
function povito_verify(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
if ($header === null || $header === '') {
return false;
}
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k === 't') {
$t = $v;
} elseif ($k === 'v1' && $v !== null) {
$signatures[] = $v;
}
}
if ($t === null || !ctype_digit($t) || abs(time() - (int) $t) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) { // constant-time
return true;
}
}
return false;
}
$raw = file_get_contents('php://input'); // raw bytes, not $_POST
$header = $_SERVER['HTTP_POVITO_SIGNATURE'] ?? null;
if (!povito_verify($raw, $header, getenv('POVITO_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// enqueue $event['id'] for processing, then:
http_response_code(200);import hashlib, hmac, json, os, time
from flask import Flask, abort, request
app = Flask(__name__)
def povito_verify(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
t = None
signatures = []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t":
t = v
elif k == "v1" and v:
signatures.append(v)
if t is None or not t.isdigit() or abs(int(time.time()) - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures) # constant-time
@app.post("/webhooks/povito")
def povito_webhook():
raw = request.get_data() # bytes as received
if not povito_verify(raw, request.headers.get("Povito-Signature"), os.environ["POVITO_WEBHOOK_SECRET"]):
abort(401)
event = json.loads(raw)
queue.enqueue(event["id"], event)
return "", 200import 'dart:convert';
import 'package:crypto/crypto.dart';
bool povitoVerify(List<int> rawBody, String? header, String secret, {int tolerance = 300}) {
if (header == null || header.isEmpty) return false;
String? t;
final signatures = <String>[];
for (final part in header.split(',')) {
final i = part.indexOf('=');
if (i < 0) continue;
final k = part.substring(0, i).trim();
final v = part.substring(i + 1).trim();
if (k == 't') t = v;
if (k == 'v1') signatures.add(v);
}
final ts = int.tryParse(t ?? '');
if (ts == null) return false;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
if ((now - ts).abs() > tolerance) return false;
final expected = Hmac(sha256, utf8.encode(secret)).convert([...utf8.encode('$ts.'), ...rawBody]).bytes;
for (final sig in signatures) {
final given = _hexToBytes(sig);
if (given != null && _constantTimeEquals(given, expected)) return true;
}
return false;
}
bool _constantTimeEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
List<int>? _hexToBytes(String hex) {
if (hex.length.isOdd) return null;
final out = <int>[];
for (var i = 0; i < hex.length; i += 2) {
final b = int.tryParse(hex.substring(i, i + 2), radix: 16);
if (b == null) return null;
out.add(b);
}
return out;
}Test your verifier against a header you sign yourself: t=1789041871, body {"ok":true}, secret whsec_test gives v1= the hex of HMAC-SHA256("whsec_test", "1789041871.{\"ok\":true}") — and shift now to check the tolerance rejects it. @povito/checkout-node ships signPayload for exactly this.
Handling events idempotently#
Deliveries retry, redelivery is a button, and the same event can reach two endpoints. Key your handling on event.id:
if (await store.has(event.id)) return ack()
const session = await povito.get(`/checkout/sessions/${event.data.object.id}`) // the truth
switch (session.payment_status) {
case "paid": await fulfil(session.reference_id, session.payment); break
case "requires_offline_collection": await dispatchForCash(session.reference_id); break
}
await store.add(event.id)
ack()Also handle late events: a checkout.session.expired can arrive after you already saw completed from a GET — the session you fetch is always the current one, so acting on the fetch rather than the payload keeps you right.
Retries and auto-disable#
A delivery is a failure when your endpoint returns anything outside 2xx, redirects, or takes longer than 10 seconds. Failures are retried on a fixed schedule:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Delay after previous | — | 1 min | 5 min | 30 min | 2 h | 6 h | 24 h | 24 h |
Eight attempts, spanning about 2½ days. If an endpoint has been failing continuously for 3 days it is disabled — status: "disabled", disabled_reason: "delivery_failures" — and the merchant contact is emailed. Deliveries to a disabled endpoint stop; events are still recorded and can be redelivered once you re-enable it:
curl -X PATCH https://api.checkout.povito.com/v1/webhook_endpoints/we_2K7A9BC4DEFGH1J3M8XQ \
-H "Authorization: Bearer $POVITO_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "status": "enabled" }'A single success resets the failure clock.
Managing endpoints#
| Call | Purpose |
|---|---|
POST /v1/webhook_endpoints |
{ url, events[], mode, description? } → the endpoint with secret, shown once. mode must match your key's mode. |
GET /v1/webhook_endpoints, GET …/{id} |
List (this mode) or read; never includes the secret. |
PATCH …/{id} |
Change url, events, description, or status: enabled | disabled. |
DELETE …/{id} |
Remove; 204. |
POST …/{id}/rotate_secret |
New secret, shown once; the old one keeps working for 24 hours. |
POST …/{id}/test |
{ type } → 202 { event_id }; a synthetic event of that type is queued to this endpoint. |
Endpoint URLs must be https://. Targets that resolve to private or link-local addresses, localhost, *.internal or *.local, or that carry credentials in the URL, are refused with 400 validation_error (details[].issue of private_target, credentials, https_required or unresolvable).
Events and endpoints are per mode: a test key registers test endpoints and receives test events; register a live endpoint with your live key before going live.
Rotating the secret#
curl -X POST https://api.checkout.povito.com/v1/webhook_endpoints/we_2K7A9BC4DEFGH1J3M8XQ/rotate_secret \
-H "Authorization: Bearer $POVITO_SECRET_KEY"For the next 24 hours every delivery is signed with both secrets — the header has two v1= entries — so you can deploy the new secret without a gap. The verifiers above already accept any matching v1.
The test tool#
curl -X POST https://api.checkout.povito.com/v1/webhook_endpoints/we_2K7A9BC4DEFGH1J3M8XQ/test \
-H "Authorization: Bearer $POVITO_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "checkout.session.completed" }'The synthetic event is signed and delivered like a real one and appears in GET /events. Its data.object is a sample marker, not a session: { "id": "cs_test_SAMPLE0000000000000000", "object": "checkout.session", "sample": true, "type": "…" }. A handler that follows the "fetch, then act" rule will get 404 session_not_found on the fetch — which is the right outcome for a test and a good check that you do not fulfil from the payload.
The event log#
Every event your objects produced is kept for 30 days and readable without webhooks — useful for reconciliation and for catching up after an outage.
curl "https://api.checkout.povito.com/v1/events?type=checkout.session.completed&since=2026-09-10T00:00:00Z" \
-H "Authorization: Bearer $POVITO_SECRET_KEY"GET /v1/events/{id} adds deliveries[]: one row per attempt per endpoint with endpoint_id, attempt_no, status_code, error (http_503, timeout, …), delivered_at and next_retry_at.
Redelivery#
curl -X POST https://api.checkout.povito.com/v1/events/evt_test_4DEFGH1J3M8XQ2K7A9BC/redeliver \
-H "Authorization: Bearer $POVITO_SECRET_KEY"202 { "endpoints": 1 } — the event is queued again to every enabled endpoint whose events match, as a fresh attempt number. The body and signature timestamp are new; event.id is the same, which is why your handler keys on it.
Checklist#
- HTTPS endpoint, public hostname, responds
2xxwithin 10 s. - Raw-body signature check, constant-time compare, 300 s tolerance, any
v1may match. - De-duplicate on
event.id; fetch the session before acting. - One endpoint per mode; keep the secret out of your repository; rotate it if it leaks.