PovitoDevelopers

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:

  1. Verify the signature on the raw bytes before you parse anything, with a constant-time compare and a five-minute timestamp window.
  2. 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#

http
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 2xx fast and do the work afterwards; a 3xx is not followed and counts as a failure.
  • Povito-Event-Id and Povito-Event-Type duplicate id and type from 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

text
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.

Nodejs
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()
})
PHPphp
<?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);
Python (Flask)python
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 "", 200
Dartdart
import '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:

js
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:

Re-enablebash
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#

bash
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#

bash
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.

bash
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#

bash
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 2xx within 10 s.
  • Raw-body signature check, constant-time compare, 300 s tolerance, any v1 may 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.