Appearance
Integration kit
We do not ship SDKs — and here is why. What we ship instead is this page: the two pieces of code integrations actually get wrong, in three languages, executed by our test suite against real generated payloads.
Every snippet below is a file in our repository. The tests assert that each one verifies a genuine delivery and rejects a tampered one — a tampered body and a tampered timestamp — so a snippet that stopped being correct fails our build instead of your integration. Hand-written examples rot; these cannot.
Verify a signature
The scheme, in one line:
X-Tesserapp-Signature: sha256=<hex HMAC-SHA256 of "{timestamp}.{rawBody}", keyed with the webhook secret>Read the raw bytes
If you parse the JSON and re-encode it before verifying, the bytes change and the signature will not match — with a perfectly correct secret. Each snippet below carries the framework-specific way to get the raw body, because this is the single most common cause of "signature doesn't match".
Node
js
// TesserApp webhooks — signature verification (Node 18+, zero dependencies).
//
// Every delivery carries these headers:
// X-Tesserapp-Event the event name, e.g. "stamp.assigned"
// X-Tesserapp-Delivery the event_id — your dedup key (same value as body.event_id)
// X-Tesserapp-Timestamp unix seconds, when we signed
// X-Tesserapp-Signature "sha256=" + hex HMAC-SHA256 of `${timestamp}.${rawBody}`
//
// ⚠️ RAW BODY. The signature is computed over the EXACT bytes we sent. If your
// framework parsed the JSON and you re-serialise it, key order and whitespace can
// differ and the signature will not match. This is the single most common cause of
// "signature doesn't match" — see the per-framework notes at the bottom of this file.
import { createHmac, timingSafeEqual } from 'node:crypto';
// How much clock skew you accept. We send the timestamp; the tolerance is YOUR
// choice — TesserApp does not enforce one on the sending side. 5 minutes is a
// sane default: long enough for clock drift, short enough that a captured
// delivery cannot be replayed days later.
export const DEFAULT_TOLERANCE_SECONDS = 300;
const headerValue = (headers, name) => {
if (!headers) return undefined;
if (typeof headers.get === 'function') return headers.get(name) ?? undefined; // fetch Headers
const direct = headers[name] ?? headers[name.toLowerCase()];
const value = direct ?? headers[Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()) ?? ''];
return Array.isArray(value) ? value[0] : value ?? undefined;
};
// Constant-time compare. Never use `===` on a signature: it returns early on the
// first differing byte, which leaks the correct prefix to a patient attacker.
const constantTimeEquals = (a, b) => {
const ab = Buffer.from(String(a), 'utf8');
const bb = Buffer.from(String(b), 'utf8');
// timingSafeEqual throws on a length mismatch, so compare lengths first — a
// length difference is not secret.
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
};
/**
* Verify one TesserApp webhook delivery.
*
* @param {object} args
* @param {Buffer|string} args.rawBody the EXACT request body bytes (not a parsed object)
* @param {object|Headers} args.headers the request headers
* @param {string} args.secret the webhook's signing secret (shown once at creation)
* @param {number} [args.toleranceSeconds]
* @param {number} [args.nowSeconds] injectable clock, for tests
* @returns {{ok: true, event: string, eventId: string} | {ok: false, reason: string}}
*/
export function verifyTesserappWebhook({
rawBody,
headers,
secret,
toleranceSeconds = DEFAULT_TOLERANCE_SECONDS,
nowSeconds = Math.floor(Date.now() / 1000),
}) {
const signature = headerValue(headers, 'x-tesserapp-signature');
const timestamp = headerValue(headers, 'x-tesserapp-timestamp');
if (!signature || !timestamp) return { ok: false, reason: 'missing_headers' };
if (!/^[0-9]+$/.test(String(timestamp))) return { ok: false, reason: 'bad_timestamp' };
// Timestamp tolerance FIRST: it is the cheap check, and it is what makes a
// captured-and-replayed delivery useless.
const skew = Math.abs(nowSeconds - Number(timestamp));
if (skew > toleranceSeconds) return { ok: false, reason: 'stale_timestamp' };
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8');
// The signed string is `${timestamp}.${rawBody}` — the timestamp is INSIDE the
// MAC, so an attacker cannot re-date a captured delivery.
const expected =
'sha256=' +
createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body]))
.digest('hex');
if (!constantTimeEquals(expected, signature)) return { ok: false, reason: 'bad_signature' };
return {
ok: true,
event: headerValue(headers, 'x-tesserapp-event') ?? '',
eventId: headerValue(headers, 'x-tesserapp-delivery') ?? '',
};
}
// --- Replay dedup ---------------------------------------------------------
//
// Delivery is AT-LEAST-ONCE: a receiver that returns 2xx slowly, or a network
// blip after your handler committed, produces a second delivery of the SAME
// event_id. Dedup on it. Suggested storage shape (any store with a unique index):
//
// CREATE TABLE tesserapp_webhook_events (
// event_id text PRIMARY KEY,
// event text NOT NULL,
// received_at timestamptz NOT NULL DEFAULT now()
// );
// -- prune rows older than your own support window; ours is 30 days.
//
// Insert-and-check in ONE statement so two concurrent deliveries cannot both win:
//
// INSERT INTO tesserapp_webhook_events (event_id, event) VALUES ($1, $2)
// ON CONFLICT (event_id) DO NOTHING RETURNING event_id;
//
// No row returned ⇒ already processed ⇒ return 200 and do nothing else.
export function makeInMemoryDedupStore() {
const seen = new Set();
return {
/** @returns {boolean} true the FIRST time an event_id is seen, false after. */
claim(eventId) {
if (seen.has(eventId)) return false;
seen.add(eventId);
return true;
},
};
}
// --- Getting the raw bytes, per framework --------------------------------
//
// express: app.post('/tesserapp', express.raw({ type: 'application/json' }), (req, res) => …)
// → req.body is a Buffer. Do NOT mount express.json() on this route.
// If express.json() is global, capture the bytes with its verify hook:
// app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }))
// koa: koa-bodyparser does not keep the raw bytes. Read them before it runs:
// const raw = await rawBody(ctx.req) // 'raw-body'
// fastify: fastify.addContentTypeParser('application/json', { parseAs: 'buffer' },
// (_req, body, done) => done(null, { raw: body }))
// next.js: App Router → const raw = await req.text()
// Pages Router → export const config = { api: { bodyParser: false } }
// lambda: the body may be base64-encoded — decode with Buffer.from(body, 'base64')
// when event.isBase64Encoded, and never JSON.parse-then-re-stringify.
//
// Why this matters more than it looks: we sign the bytes as they leave our
// worker, which re-serialises the stored delivery payload. Any re-serialisation
// on YOUR side can order keys differently. Verify the bytes, then parse.PHP
php
<?php
// TesserApp webhooks — signature verification (PHP 7.4+, no dependencies).
//
// Every delivery carries these headers:
// X-Tesserapp-Event the event name, e.g. "stamp.assigned"
// X-Tesserapp-Delivery the event_id — your dedup key (same value as body.event_id)
// X-Tesserapp-Timestamp unix seconds, when we signed
// X-Tesserapp-Signature "sha256=" + hex HMAC-SHA256 of "{timestamp}.{rawBody}"
//
// ⚠️ RAW BODY. Read php://input. Do NOT use $_POST (it is empty for a JSON body
// anyway) and never json_decode-then-json_encode before verifying: PHP's encoder
// will not reproduce our byte order and the signature will not match. This is the
// single most common cause of "signature doesn't match".
//
// $rawBody = file_get_contents('php://input');
//
// Laravel: $rawBody = $request->getContent();
// Exclude the route from VerifyCsrfToken — a webhook has no session.
// Symfony: $rawBody = $request->getContent();
// Slim: $rawBody = (string) $request->getBody();
// WordPress: use a rest_api_init route and $request->get_body().
// If a proxy or ModSecurity rewrites the body (adding a trailing newline is the
// classic one), the signature fails — compare strlen($rawBody) with
// Content-Length before blaming the secret.
// How much clock skew you accept. We send the timestamp; the tolerance is YOUR
// choice — TesserApp does not enforce one on the sending side. 5 minutes is a
// sane default: long enough for clock drift, short enough that a captured
// delivery cannot be replayed days later.
const TESSERAPP_DEFAULT_TOLERANCE_SECONDS = 300;
/**
* Verify one TesserApp webhook delivery.
*
* @param string $rawBody the EXACT request body bytes (php://input)
* @param array $headers header name => value (case-insensitive lookup below)
* @param string $secret the webhook's signing secret (shown once at creation)
* @return array{ok: bool, reason?: string, event?: string, event_id?: string}
*/
function tesserapp_verify_webhook(
string $rawBody,
array $headers,
string $secret,
int $toleranceSeconds = TESSERAPP_DEFAULT_TOLERANCE_SECONDS,
?int $nowSeconds = null
): array {
$now = $nowSeconds ?? time();
$signature = tesserapp_header($headers, 'x-tesserapp-signature');
$timestamp = tesserapp_header($headers, 'x-tesserapp-timestamp');
if ($signature === null || $timestamp === null) {
return ['ok' => false, 'reason' => 'missing_headers'];
}
if (preg_match('/^[0-9]+$/', $timestamp) !== 1) {
return ['ok' => false, 'reason' => 'bad_timestamp'];
}
// Timestamp tolerance FIRST: it is the cheap check, and it is what makes a
// captured-and-replayed delivery useless.
if (abs($now - (int) $timestamp) > $toleranceSeconds) {
return ['ok' => false, 'reason' => 'stale_timestamp'];
}
// The signed string is "{timestamp}.{rawBody}" — the timestamp is INSIDE the
// MAC, so an attacker cannot re-date a captured delivery.
$signed = $timestamp . '.' . $rawBody;
$expected = 'sha256=' . hash_hmac('sha256', $signed, $secret);
// hash_equals is the constant-time compare. Never use === on a signature: it
// returns early on the first differing byte.
if (!hash_equals($expected, $signature)) {
return ['ok' => false, 'reason' => 'bad_signature'];
}
return [
'ok' => true,
'event' => tesserapp_header($headers, 'x-tesserapp-event') ?? '',
'event_id' => tesserapp_header($headers, 'x-tesserapp-delivery') ?? '',
];
}
function tesserapp_header(array $headers, string $name): ?string
{
foreach ($headers as $key => $value) {
if (strcasecmp((string) $key, $name) === 0) {
return is_array($value) ? (string) reset($value) : (string) $value;
}
}
// Fallback for raw PHP: HTTP_X_TESSERAPP_SIGNATURE in $_SERVER.
$cgi = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
return isset($headers[$cgi]) ? (string) $headers[$cgi] : null;
}
// --- Replay dedup ---------------------------------------------------------
//
// Delivery is AT-LEAST-ONCE: a slow 2xx or a network blip after your handler
// committed produces a second delivery of the SAME event_id. Dedup on it.
// Suggested storage shape (MySQL/Postgres — any store with a unique index):
//
// CREATE TABLE tesserapp_webhook_events (
// event_id VARCHAR(64) PRIMARY KEY,
// event VARCHAR(64) NOT NULL,
// received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
// );
// -- prune rows older than your own support window; ours is 30 days.
//
// Claim it in ONE statement so two concurrent deliveries cannot both win:
//
// $stmt = $pdo->prepare(
// 'INSERT INTO tesserapp_webhook_events (event_id, event) VALUES (?, ?)
// ON CONFLICT (event_id) DO NOTHING' // MySQL: INSERT IGNORE
// );
// $stmt->execute([$eventId, $event]);
// $isFirstDelivery = $stmt->rowCount() === 1;
//
// Not the first delivery ⇒ respond 200 and do nothing else.
// --- A complete endpoint --------------------------------------------------
//
// $rawBody = file_get_contents('php://input');
// $result = tesserapp_verify_webhook($rawBody, getallheaders(), getenv('TESSERAPP_WEBHOOK_SECRET'));
// if (!$result['ok']) { http_response_code(400); echo $result['reason']; exit; }
// // Verify FIRST, parse SECOND.
// $payload = json_decode($rawBody, true);
// // …claim $result['event_id'], then do the work, then:
// http_response_code(200);
//
// Return 2xx quickly and do the work asynchronously: a delivery that does not
// answer within the delivery timeout is recorded as failed and retried.Python
python
"""TesserApp webhooks - signature verification (Python 3.8+, standard library only).
Every delivery carries these headers:
X-Tesserapp-Event the event name, e.g. "stamp.assigned"
X-Tesserapp-Delivery the event_id - your dedup key (same value as body.event_id)
X-Tesserapp-Timestamp unix seconds, when we signed
X-Tesserapp-Signature "sha256=" + hex HMAC-SHA256 of "{timestamp}.{raw_body}"
RAW BODY WARNING. The signature is computed over the EXACT bytes we sent. Never
json.loads then json.dumps before verifying - Python's encoder inserts spaces
after ":" and "," by default and will not reproduce our bytes. This is the single
most common cause of "signature doesn't match".
Flask: raw_body = request.get_data() # bytes; NOT request.json
Django: raw_body = request.body # exempt the view from CSRF
FastAPI: raw_body = await request.body()
Starlette same as FastAPI. aiohttp: raw_body = await request.read()
Verify first, parse second.
"""
import hashlib
import hmac
import re
import time
from typing import Mapping, Optional, Union
# How much clock skew you accept. We send the timestamp; the tolerance is YOUR
# choice - TesserApp does not enforce one on the sending side. 5 minutes is a
# sane default: long enough for clock drift, short enough that a captured
# delivery cannot be replayed days later.
DEFAULT_TOLERANCE_SECONDS = 300
_DIGITS = re.compile(r"^[0-9]+$")
def verify_tesserapp_webhook(
raw_body: Union[bytes, str],
headers: Mapping[str, str],
secret: str,
tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
now_seconds: Optional[int] = None,
) -> dict:
"""Verify one delivery. Returns {"ok": True, ...} or {"ok": False, "reason": ...}."""
now = int(time.time()) if now_seconds is None else now_seconds
signature = _header(headers, "x-tesserapp-signature")
timestamp = _header(headers, "x-tesserapp-timestamp")
if not signature or not timestamp:
return {"ok": False, "reason": "missing_headers"}
if not _DIGITS.match(timestamp):
return {"ok": False, "reason": "bad_timestamp"}
# Timestamp tolerance FIRST: it is the cheap check, and it is what makes a
# captured-and-replayed delivery useless.
if abs(now - int(timestamp)) > tolerance_seconds:
return {"ok": False, "reason": "stale_timestamp"}
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
# The signed string is "{timestamp}.{raw_body}" - the timestamp is INSIDE the
# MAC, so an attacker cannot re-date a captured delivery.
signed = timestamp.encode("ascii") + b"." + body
expected = "sha256=" + hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
# compare_digest is the constant-time compare. Never use == on a signature:
# it returns early on the first differing byte.
if not hmac.compare_digest(expected, signature):
return {"ok": False, "reason": "bad_signature"}
return {
"ok": True,
"event": _header(headers, "x-tesserapp-event") or "",
"event_id": _header(headers, "x-tesserapp-delivery") or "",
}
def _header(headers: Mapping[str, str], name: str) -> Optional[str]:
for key, value in headers.items():
if str(key).lower() == name:
return value if isinstance(value, str) else str(value)
return None
# --- Replay dedup ---------------------------------------------------------
#
# Delivery is AT-LEAST-ONCE: a slow 2xx or a network blip after your handler
# committed produces a second delivery of the SAME event_id. Dedup on it.
# Suggested storage shape (any store with a unique index):
#
# CREATE TABLE tesserapp_webhook_events (
# event_id text PRIMARY KEY,
# event text NOT NULL,
# received_at timestamptz NOT NULL DEFAULT now()
# );
# -- prune rows older than your own support window; ours is 30 days.
#
# Claim it in ONE statement so two concurrent deliveries cannot both win:
#
# cur.execute(
# "INSERT INTO tesserapp_webhook_events (event_id, event) VALUES (%s, %s) "
# "ON CONFLICT (event_id) DO NOTHING RETURNING event_id",
# (event_id, event),
# )
# is_first_delivery = cur.fetchone() is not None
#
# Not the first delivery => respond 200 and do nothing else.
#
#
# --- A complete Flask endpoint -------------------------------------------
#
# @app.post("/tesserapp")
# def tesserapp_webhook():
# result = verify_tesserapp_webhook(request.get_data(), request.headers, SECRET)
# if not result["ok"]:
# return result["reason"], 400
# payload = json.loads(request.get_data()) # verified bytes, now parse
# if claim(result["event_id"]):
# handle(payload)
# return "", 200
#
# Return 2xx quickly and do the work asynchronously: a delivery that does not
# answer within the delivery timeout is recorded as failed and retried.Replay dedup
Delivery is at-least-once, so build for duplicates. Each snippet above ends with the storage shape and the single-statement claim — a table keyed on event_id, an INSERT … ON CONFLICT DO NOTHING, and "no row inserted" meaning "already processed, return 200 and stop".
Claim the id in one statement rather than checking-then-inserting: two concurrent deliveries of the same event would otherwise both pass the check.
Retrying a write safely
Send an Idempotency-Key on every write and a retry is safe. Three outcomes matter, and the third is the one that produces support tickets:
| Response | Do |
|---|---|
| 2xx | Done. |
| 429 / 5xx / network error | Retry with the same key, honouring Retry-After. |
409 idempotency_conflict | Do not retry. You reused a key with a different body, or an earlier attempt claimed it and died. |
The trap worth naming: a fresh key per attempt is not a retry, it is a second write. And serialise the body once, outside the loop — re-encoding per attempt is how a retry ends up with different bytes and earns the 409.
Node
js
// TesserApp REST API — retrying a write safely (Node 18+, zero dependencies).
//
// Every POST / PATCH / DELETE accepts an `Idempotency-Key` header. Send one and a
// retry is safe: the first request's response is replayed instead of the write
// happening twice. Send none and the request is still processed — you just have no
// safe retry, so a timeout leaves you unable to tell whether the card was issued.
//
// Three outcomes you must handle, and the third is the one integrations get wrong:
// 1. 2xx — done. Store the response.
// 2. 429 / 5xx / network error — retry with the SAME key. That is the point.
// 3. 409 idempotency_conflict — you reused a key with a DIFFERENT body.
// Retrying cannot fix it. Fix the caller.
import { randomUUID } from 'node:crypto';
// Any string you can regenerate for the same logical operation. A UUID per
// operation is fine; deriving it from your own record id (e.g.
// `issue-card:EMP-00418`) is better, because a crashed-and-restarted job
// regenerates the same key and stays idempotent.
export const newIdempotencyKey = () => randomUUID();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* POST a write with an idempotency key, retrying transient failures with the
* SAME key and never retrying a conflict.
*/
export async function postIdempotent({
url,
apiKey,
body,
idempotencyKey = newIdempotencyKey(),
maxAttempts = 5,
fetchImpl = fetch,
sleepImpl = sleep,
}) {
// Serialise ONCE, outside the retry loop. Re-serialising per attempt is how a
// retry ends up with a different body — and therefore a 409 — under the same key.
const payload = JSON.stringify(body);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let res;
try {
res = await fetchImpl(url, {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
// Same key on every attempt. A fresh key per attempt is not a retry,
// it is a second write.
'idempotency-key': idempotencyKey,
},
body: payload,
});
} catch (networkError) {
if (attempt === maxAttempts) throw networkError;
await sleepImpl(backoffMs(attempt));
continue;
}
const text = await res.text();
const parsed = text ? safeJson(text) : null;
if (res.ok) {
return { status: res.status, body: parsed, idempotencyKey, attempts: attempt };
}
// --- The 409 case, in full. -------------------------------------------
// The error envelope is the same everywhere: { error: { code, message, request_id } }.
// `idempotency_conflict` means one of two things, and neither is retryable
// with this key:
// a) this key was already used with a different request body — a caller bug;
// b) this key was claimed by a request that never completed — start over
// with a NEW key (the message says which).
if (res.status === 409 && parsed?.error?.code === 'idempotency_conflict') {
throw Object.assign(new Error(`idempotency conflict: ${parsed.error.message}`), {
code: 'idempotency_conflict',
requestId: parsed.error.request_id, // quote this on a support ticket
retryable: false,
});
}
// 429 → honour Retry-After. The limit is per key, so backing off on this key
// does not help another integration you run: give each its own key.
if (res.status === 429) {
const retryAfter = Number(res.headers.get('retry-after'));
if (attempt === maxAttempts) throw httpError(res.status, parsed);
await sleepImpl(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : backoffMs(attempt));
continue;
}
// 5xx → transient, same key.
if (res.status >= 500 && attempt < maxAttempts) {
await sleepImpl(backoffMs(attempt));
continue;
}
// Everything else is a decision you must make, not a retry: 400 validation,
// 401 (a bad key, OR business_api_required — read error.code, the status
// alone does not tell you which), 402 suspended subscription, 403 missing
// scope, 404.
throw httpError(res.status, parsed);
}
throw new Error('unreachable');
}
const backoffMs = (attempt) => Math.min(30_000, 2 ** (attempt - 1) * 1_000);
const safeJson = (text) => {
try {
return JSON.parse(text);
} catch {
return null;
}
};
const httpError = (status, parsed) =>
Object.assign(new Error(parsed?.error?.message ?? `HTTP ${status}`), {
status,
code: parsed?.error?.code,
requestId: parsed?.error?.request_id,
});PHP
php
<?php
// TesserApp REST API - retrying a write safely (PHP 7.4+, cURL, no dependencies).
//
// Every POST / PATCH / DELETE accepts an `Idempotency-Key` header. Send one and a
// retry is safe: the first request's response is replayed instead of the write
// happening twice. Send none and the request is still processed - you just have no
// safe retry, so a timeout leaves you unable to tell whether the card was issued.
//
// Three outcomes, and the third is the one integrations get wrong:
// 1. 2xx - done.
// 2. 429 / 5xx / network error - retry with the SAME key. That is the point.
// 3. 409 idempotency_conflict - you reused a key with a DIFFERENT body.
// Retrying cannot fix it. Fix the caller.
/**
* @param array $body decoded request body
* @return array{status:int, body:mixed, idempotency_key:string, attempts:int}
* @throws RuntimeException
*/
function tesserapp_post_idempotent(
string $url,
string $apiKey,
array $body,
?string $idempotencyKey = null,
int $maxAttempts = 5
): array {
// Any string you can regenerate for the same logical operation. Deriving it
// from your own record id ("issue-card:EMP-00418") is better than a random
// one: a crashed-and-restarted job regenerates the same key and stays
// idempotent.
$key = $idempotencyKey ?? bin2hex(random_bytes(16));
// Encode ONCE, outside the retry loop. Re-encoding per attempt is how a retry
// ends up with a different body - and therefore a 409 - under the same key.
$payload = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
// Same key on every attempt. A fresh key per attempt is not a
// retry, it is a second write.
'Idempotency-Key: ' . $key,
],
]);
$raw = curl_exec($ch);
$errno = curl_errno($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($errno !== 0) { // network error - retryable
if ($attempt === $maxAttempts) {
throw new RuntimeException('tesserapp: network error ' . $errno);
}
usleep(tesserapp_backoff_ms($attempt) * 1000);
continue;
}
$rawHeaders = substr((string) $raw, 0, $headerSize);
$parsed = json_decode(substr((string) $raw, $headerSize), true);
if ($status >= 200 && $status < 300) {
return ['status' => $status, 'body' => $parsed, 'idempotency_key' => $key, 'attempts' => $attempt];
}
// The 409 case, in full. The error envelope is the same everywhere:
// { "error": { "code": ..., "message": ..., "request_id": ... } }.
// `idempotency_conflict` means one of two things, and neither is
// retryable with this key:
// a) the key was already used with a different request body - a bug;
// b) the key was claimed by a request that never completed - start
// over with a NEW key (the message says which).
if ($status === 409 && (($parsed['error']['code'] ?? null) === 'idempotency_conflict')) {
throw new RuntimeException(sprintf(
'tesserapp: idempotency conflict (request_id %s): %s',
$parsed['error']['request_id'] ?? '-', // quote this on a support ticket
$parsed['error']['message'] ?? ''
));
}
// 429 - honour Retry-After. The limit is per key, so give each
// integration you run its own key.
if ($status === 429 && $attempt < $maxAttempts) {
$retryAfter = 0;
if (preg_match('/^retry-after:\s*(\d+)/im', $rawHeaders, $m) === 1) {
$retryAfter = (int) $m[1];
}
usleep(($retryAfter > 0 ? $retryAfter * 1000 : tesserapp_backoff_ms($attempt)) * 1000);
continue;
}
if ($status >= 500 && $attempt < $maxAttempts) { // transient, same key
usleep(tesserapp_backoff_ms($attempt) * 1000);
continue;
}
// Everything else is a decision you must make, not a retry:
// 400 validation, 401 (a bad key, OR business_api_required — read
// error.code, the status alone does not tell you which), 402 suspended
// subscription, 403 missing scope, 404.
throw new RuntimeException(sprintf(
'tesserapp: HTTP %d %s (%s)',
$status,
$parsed['error']['code'] ?? '',
$parsed['error']['message'] ?? ''
));
}
throw new RuntimeException('unreachable');
}
function tesserapp_backoff_ms(int $attempt): int
{
return (int) min(30000, (2 ** ($attempt - 1)) * 1000);
}Python
python
"""TesserApp REST API - retrying a write safely (Python 3.8+, standard library only).
Every POST / PATCH / DELETE accepts an `Idempotency-Key` header. Send one and a
retry is safe: the first request's response is replayed instead of the write
happening twice. Send none and the request is still processed - you just have no
safe retry, so a timeout leaves you unable to tell whether the card was issued.
Three outcomes, and the third is the one integrations get wrong:
1. 2xx - done.
2. 429 / 5xx / network error - retry with the SAME key. That is the point.
3. 409 idempotency_conflict - you reused a key with a DIFFERENT body.
Retrying cannot fix it. Fix the caller.
(`requests` works the same way; only the transport lines change.)
"""
import json
import time
import urllib.error
import urllib.request
import uuid
from typing import Any, Dict, Optional
class TesserappError(RuntimeError):
def __init__(self, message: str, status: Optional[int] = None,
code: Optional[str] = None, request_id: Optional[str] = None,
retryable: bool = False):
super().__init__(message)
self.status = status
self.code = code
self.request_id = request_id # quote this on a support ticket
self.retryable = retryable
def new_idempotency_key() -> str:
"""Any string you can regenerate for the same logical operation. Deriving it
from your own record id ("issue-card:EMP-00418") is better than a random one:
a crashed-and-restarted job regenerates the same key and stays idempotent."""
return str(uuid.uuid4())
def post_idempotent(
url: str,
api_key: str,
body: Dict[str, Any],
idempotency_key: Optional[str] = None,
max_attempts: int = 5,
sleep=time.sleep,
) -> Dict[str, Any]:
key = idempotency_key or new_idempotency_key()
# Encode ONCE, outside the retry loop. Re-encoding per attempt is how a retry
# ends up with a different body - and therefore a 409 - under the same key.
payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
for attempt in range(1, max_attempts + 1):
request = urllib.request.Request(
url,
data=payload,
method="POST",
headers={
"Authorization": "Bearer " + api_key,
"Content-Type": "application/json",
# Same key on every attempt. A fresh key per attempt is not a
# retry, it is a second write.
"Idempotency-Key": key,
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
parsed = _json_or_none(response.read())
return {"status": response.status, "body": parsed,
"idempotency_key": key, "attempts": attempt}
except urllib.error.HTTPError as http_error:
status = http_error.code
parsed = _json_or_none(http_error.read())
error = (parsed or {}).get("error") or {}
# The 409 case, in full. The error envelope is the same everywhere:
# {"error": {"code": ..., "message": ..., "request_id": ...}}.
# `idempotency_conflict` means one of two things, and neither is
# retryable with this key:
# a) the key was already used with a different request body - a bug;
# b) the key was claimed by a request that never completed - start
# over with a NEW key (the message says which).
if status == 409 and error.get("code") == "idempotency_conflict":
raise TesserappError(
"idempotency conflict: " + str(error.get("message")),
status=status, code="idempotency_conflict",
request_id=error.get("request_id"), retryable=False,
)
# 429 - honour Retry-After. The limit is per key, so give each
# integration you run its own key.
if status == 429 and attempt < max_attempts:
retry_after = http_error.headers.get("Retry-After")
sleep(int(retry_after) if retry_after and retry_after.isdigit()
else _backoff_seconds(attempt))
continue
if status >= 500 and attempt < max_attempts: # transient, same key
sleep(_backoff_seconds(attempt))
continue
# Everything else is a decision you must make, not a retry:
# 400 validation, 401 (a bad key, OR business_api_required — read
# error.code, the status alone does not tell you which), 402
# suspended subscription, 403 missing scope, 404.
raise TesserappError(
str(error.get("message") or ("HTTP %d" % status)),
status=status, code=error.get("code"),
request_id=error.get("request_id"),
)
except urllib.error.URLError: # network error
if attempt == max_attempts:
raise
sleep(_backoff_seconds(attempt))
raise TesserappError("unreachable")
def _backoff_seconds(attempt: int) -> float:
return min(30.0, float(2 ** (attempt - 1)))
def _json_or_none(raw: bytes) -> Optional[Dict[str, Any]]:
try:
return json.loads(raw.decode("utf-8"))
except Exception:
return NoneSee Idempotency for the semantics behind these loops.
Generate a typed client
If you want a client library, generate one from our OpenAPI document. It will always be current, because it is generated from the document CI verifies against the live routes — and we maintain nothing.
WARNING
This needs the generated reference, which is published with the Business API. The commands below are the intended ones and the document URL is the intended path — both work the day the reference is published, and not before.
Substitute the published document URL for <OPENAPI_URL> (it is linked from the reference once it ships), or a local copy of the file.
bash
# Types only — no runtime, no dependency to keep updated. The usual choice.
npx openapi-typescript <OPENAPI_URL> -o tesserapp.d.ts
# Or a full client with fetch built in:
npx openapi-generator-cli generate \
-i <OPENAPI_URL> \
-g typescript-fetch \
-o ./tesserapp-clientbash
npx openapi-generator-cli generate \
-i <OPENAPI_URL> \
-g php \
-o ./tesserapp-client
# Guzzle-based. Composer-install the generated package from a path repository.bash
npx openapi-generator-cli generate \
-i <OPENAPI_URL> \
-g python \
-o ./tesserapp-client
# urllib3-based, with type hints.Caveats worth knowing before you commit to a generated client:
- Pin the document version you generated from, and regenerate deliberately. A client regenerated automatically on every build turns our additive change into your build failure.
- Generated code is not idiomatic. Method names come from operation ids and the models are verbose. For a handful of endpoints,
fetchwith the TypeScript types is usually the better trade. - The generated client will not handle idempotent retries or webhook signatures for you. Those are the snippets above, and they are the parts that matter.
- Our API is additive inside
/v1(changelog), so a client generated today keeps working. It simply will not know about fields added later.
A Postman / Bruno collection
A collection is generated from the same OpenAPI document, in the same CI step, so it cannot drift from the reference either. Until then, importing openapi.json into Postman, Bruno or Insomnia gives you the same thing.