Vouchereto API · v1
Webhooks
We POST a signed JSON event to your endpoint when a redemption or checkout changes state. Use webhooks as the source of truth for crediting customers; redirects can be lost or forged.
Set up an endpoint
- Merchant portal → Developers → Webhooks, in test mode first. Add a public
https://URL and pick the events. - Copy the
whsec_…secret (shown once). Each endpoint has its own secret. - Up to 5 active endpoints per mode. Test endpoints receive test events only, live endpoints live events only.
Events
| Event | Sent when | Typical action |
|---|---|---|
redemption.captured | A redemption is captured (by you, or by the hosted checkout / widget with auto capture) | Credit the customer (if you have not already on the API response) |
redemption.cancelled | A reservation is cancelled | Release anything you held for it |
redemption.expired | A reservation lapsed without capture | Do not credit; the voucher is usable again |
checkout.completed | A hosted checkout was paid and finished (with auto_redeem the voucher is already captured: see data.redemption_id) | Credit the customer |
checkout.expired | A checkout session passed its 60-minute window unpaid | Clean up the pending top-up |
checkout.failed | The payment failed | Let the customer try again |
Payload
POST /hooks/vouchereto HTTP/1.1
Content-Type: application/json
User-Agent: Vouchereto-Webhooks/1.0
Vouchereto-Event-Id: 7b0c3f9e-41d2-4c55-9a51-0d3e2a8f6b11
Vouchereto-Signature: t=1792663200,v1=0f4c…e91a
{
"id": "7b0c3f9e-41d2-4c55-9a51-0d3e2a8f6b11",
"type": "redemption.captured",
"created_at": "2026-10-22T17:20:00.312Z",
"livemode": false,
"data": {
"id": "f4e8b730-294e-4351-a85d-dc525dcadc18",
"status": "captured",
"amount_minor": 2500,
"currency": "USD",
"customer_ref": "user_8842",
"voucher_last4": "2WNZ",
"captured_at": "2026-10-22T17:20:00.298Z"
}
}
Redemption events carry id, status, amount_minor, currency, customer_ref, voucher_last4, captured_at. Checkout events carry id, status, amount_minor, currency, customer_ref, redemption_id, metadata (the metadata you sent when creating the session).
Verify the signature
The header is Vouchereto-Signature: t=<unix>,v1=<hex> where v1 is HMAC-SHA256 with your whsec_… secret over t + "." + raw_body. Verify against the raw bytes before any JSON parsing, compare in constant time and reject timestamps older than 5 minutes.
// Express: verify before parsing. The signature covers the RAW body.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyWebhook(rawBody, header, secret, tolerance = 300) {
const parts = (header ?? '').split(',').map((p) => p.trim().split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => Buffer.from(v ?? '', 'hex'));
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) throw new Error('stale or malformed signature');
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
if (!sigs.some((s) => s.length === expected.length && timingSafeEqual(s, expected))) throw new Error('bad signature');
return JSON.parse(rawBody);
}
const app = express();
app.post('/hooks/vouchereto', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try { event = verifyWebhook(req.body.toString('utf8'), req.get('Vouchereto-Signature'), process.env.VOUCHERETO_WEBHOOK_SECRET); }
catch { return res.sendStatus(400); }
// Deliveries can repeat: skip events you already processed (event.id === Vouchereto-Event-Id header)
// if (await seen(event.id)) return res.sendStatus(200);
if (event.type === 'redemption.captured') { /* credit event.data.customer_ref with event.data.amount_minor */ }
res.sendStatus(200); // answer within 10 s; do slow work in a queue
});
# Flask: verify before parsing. The signature covers the RAW body.
import hashlib, hmac, json, os, time
def verify_webhook(raw: bytes, header: str, secret: str, tolerance: int = 300) -> dict:
items = [p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p]
t = next((int(v) for k, v in items if k == "t"), None)
sigs = [v for k, v in items if k == "v1"]
if t is None or abs(time.time() - t) > tolerance:
raise ValueError("stale or malformed signature")
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, s) for s in sigs):
raise ValueError("bad signature")
return json.loads(raw)
# from flask import Flask, request
# app = Flask(__name__)
# @app.post("/hooks/vouchereto")
# def hook():
# try:
# event = verify_webhook(request.get_data(), request.headers.get("Vouchereto-Signature"), os.environ["VOUCHERETO_WEBHOOK_SECRET"])
# except ValueError:
# return "", 400
# # skip if event["id"] was already processed, then handle event["type"]
# return "", 200
<?php
// Plain PHP: verify before parsing. The signature covers the RAW body.
function verify_webhook(string $raw, ?string $header, string $secret, int $tolerance = 300): array {
$t = null; $sigs = [];
foreach (explode(',', $header ?? '') as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') $t = (int) $v;
if ($k === 'v1') $sigs[] = $v;
}
if ($t === null || abs(time() - $t) > $tolerance) throw new RuntimeException('stale or malformed signature');
$expected = hash_hmac('sha256', "$t.$raw", $secret);
foreach ($sigs as $s) if (hash_equals($expected, $s)) return json_decode($raw, true);
throw new RuntimeException('bad signature');
}
// $raw = file_get_contents('php://input');
// try { $event = verify_webhook($raw, $_SERVER['HTTP_VOUCHERETO_SIGNATURE'] ?? null, getenv('VOUCHERETO_WEBHOOK_SECRET')); }
// catch (RuntimeException $e) { http_response_code(400); exit; }
// // skip if $event['id'] was already processed, then handle $event['type']
// http_response_code(200);
verifyWebhook(rawBody, header, secret) does all of this and returns the typed event. SDKDelivery rules
| Success | Any 2xx within 10 seconds. Redirects are not followed. |
|---|---|
| Retries | 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, 24 h after each failure (about 3 days), then the delivery is marked dead. |
| Duplicates | Possible (a timeout after you processed it, a manual resend). Deduplicate on id / the Vouchereto-Event-Id header. |
| Order | Not guaranteed across events. Use data.status and timestamps, or read the object with the API. |
| Resend | Portal → Developers → Webhooks → deliveries: see every attempt with the response code and resend any delivery. |
| User agent | Vouchereto-Webhooks/1.0 |
Answer fast and do slow work in a queue. Since an event can arrive before your own API call returns, make crediting idempotent on the redemption id.