Webhooks
Hear about a new testimonial, an approval, a finished reel or a ready export the moment it
happens, instead of asking the API every few minutes. You give Akteora an HTTPS URL and the
events you want; Akteora sends each one there as a signed POST.
Set one up
In the dashboard: Developers → Webhooks → Add endpoint. Or with a key that has the
webhooks:manage scope:
curl https://api.akteora.com/v1/webhooks \
-H "Authorization: Bearer $AKTEORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.northwind.example/akteora",
"events": ["submission.created", "submission.approved"]
}'
{
"id": "whe_01K5B2E4G6J8M0P2R4T6W8Y0A2",
"url": "https://hooks.northwind.example/akteora",
"events": ["submission.created", "submission.approved"],
"status": "active",
"failing_since": null,
"disabled_at": null,
"created_at": "2026-09-15T12:00:00.000Z",
"secret": "whsec_Q0h8v3kZr5pN2wYc7tL9xB4mF6aJ1dG8sE3uK0iV7qT"
}
Store secret now. It is in this response once and never again. It is what proves a request
came from Akteora (below). If it is lost, delete the endpoint and add it again; the new one gets a
new secret.
The URL must be https:// and reach the public internet: an address inside a private network,
localhost, or a cloud metadata address is refused, both when the endpoint is added and on every
delivery. While you build a receiver on your own machine, point the endpoint at a tunnel
(cloudflared, ngrok) that gives it a public HTTPS address.
Send yourself a sample of any event without waiting for a real one:
curl -X POST https://api.akteora.com/v1/webhooks/whe_01K5B2E4G6J8M0P2R4T6W8Y0A2/test \
-H "Authorization: Bearer $AKTEORA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "submission.approved" }'
A sample is signed and retried exactly like a real event. Its ids refer to nothing.
The events
| Event | Sent when | data |
|---|---|---|
submission.created |
a respondent sends a testimonial | the testimonial |
submission.ready |
its recording has been processed and can be played (a written one: straight after submission.created) |
the testimonial |
submission.approved |
someone approves it | the testimonial |
submission.rejected |
someone rejects it | the testimonial |
reel.ready |
a reel finished rendering | the reel and render |
reel.failed |
a reel could not be rendered | the reel, render and why |
export.ready |
an export's file is ready to download | the export |
Every event arrives in the same envelope:
{
"id": "evt_01K5B3F4A6C8E0G2J4M6P8R0T2",
"type": "submission.approved",
"api_version": "v1",
"created_at": "2026-09-15T11:02:55.000Z",
"org_id": "org_01K4ZT8N2M6Q9R3V5X7B1D3F5H",
"mode": "live",
"data": {
"id": "sub_01K5A3M2Q8WJ4T6V8X0Y2A4C6E",
"brand_id": "brd_01K59Y7T3HR2D6FBJ3M1Q0W8ZK",
"link_id": "lnk_01K59Y9C4XG5N0J8S2V7A1B3DE",
"kind": "video",
"status": "ready",
"is_approved": true,
"is_public": true,
"duration_ms": 48200,
"created_at": "2026-09-15T09:41:07.312Z"
}
}
idis the event's. It is the same in every retry and in a replay: deduplicate on it. An event can arrive more than once — your 200 was lost on its way back, or a replay was sent.- Events are not guaranteed to arrive in the order they happened.
submission.approvedcan arrive beforesubmission.readyif the receiver was down when the first was sent. Read the resource if order matters. datais deliberately thin: ids and state at the moment of the event, and nothing about the respondent. Fetch the resource for the rest (GET /v1/submissions/{id},GET /v1/reels/{id},GET /v1/exports/{id}). A delivery log never holds a person's name or words that way, and an erased testimonial leaves nothing of its respondent behind in anyone's log.modeistestfor events from test mode. Test-mode events go only to endpoints added with a test key, and live events only to live endpoints.api_versionis the payload's shape. New fields and new event types are added withinv1; anything that would break a receiver comes asv2, which you opt into (versioning).
The OpenAPI document's webhooks section describes each event's schema, and the
@akteora/sdk types them (WebhookEvent).
The request
POST /akteora HTTP/1.1
Host: hooks.northwind.example
Content-Type: application/json
User-Agent: Akteora-Webhooks/1 (+https://docs.akteora.com/webhooks)
X-Signature: t=1757937775,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Akteora-Event: submission.approved
X-Akteora-Event-Id: evt_01K5B3F4A6C8E0G2J4M6P8R0T2
X-Akteora-Delivery: whd_01K5B3F5H7K9N1Q3S5V7X9Z1B3
X-Akteora-Attempt: 2
{"id":"evt_01K5B3F4A6C8E0G2J4M6P8R0T2","type":"submission.approved", …}
Answer with any 2xx within 10 seconds. Do the work after answering — put the event on your
own queue and return. Anything else counts as a failure and is retried: a 4xx, a 5xx, a
redirect (redirects are not followed), a timeout, a refused connection, a certificate the client
refuses.
Verify the signature
Anyone can send a POST to your URL. X-Signature is how you know Akteora sent this one, unchanged:
X-Signature: t=<unix seconds>,v1=<hex>
v1 is the HMAC-SHA256, keyed with the endpoint's secret, of the timestamp, a full stop, and the
raw request body — the bytes as they arrived, before any JSON parsing:
v1 = hex(HMAC_SHA256(secret, "{t}.{raw body}"))
Three rules make it safe:
- Use the raw body. Parse the JSON only after verifying. A body that was parsed and re-serialised will not match: key order and spacing change.
- Compare in constant time (
timingSafeEqual,hmac.compare_digest,hash_equals). An ordinary==stops at the first differing character, and how long it took tells an attacker how much of a forged signature was right. - Refuse a timestamp more than five minutes from your clock. The timestamp is inside the signed string, so it cannot be changed; refusing old ones makes a recorded request worthless five minutes later. Keep your server's clock on NTP.
There may be more than one v1 in the header — a match on any of them is a match. The code below
handles that.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
/** `rawBody` is the request body exactly as received: a Buffer or a string. */
export function verifyAkteoraSignature(secret, header, rawBody) {
let timestamp = null
const signatures = []
for (const part of header.split(',')) {
const [key, value] = part.split('=', 2)
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value)
if (key === 'v1') signatures.push(value)
}
if (timestamp === null || signatures.length === 0) return false
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest()
return signatures.some((signature) => {
const given = Buffer.from(signature, 'hex')
return given.length === expected.length && timingSafeEqual(given, expected)
})
}
With Express, keep the body raw on this route:
import express from 'express'
const app = express()
app.post('/akteora', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-Signature') ?? ''
if (!verifyAkteoraSignature(process.env.AKTEORA_WEBHOOK_SECRET, signature, req.body)) {
return res.sendStatus(400)
}
const event = JSON.parse(req.body)
queue.add(event) // your own queue: answer first, work after
res.sendStatus(204)
})
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify_akteora_signature(secret: str, header: str, raw_body: bytes) -> bool:
"""raw_body is the request body exactly as received, as bytes."""
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"), f"{timestamp}.".encode("utf-8") + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
With Flask, request.get_data() is the raw body:
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/akteora")
def akteora():
raw = request.get_data()
if not verify_akteora_signature(SECRET, request.headers.get("X-Signature", ""), raw):
abort(400)
event = request.get_json()
enqueue(event) # answer first, work after
return "", 204
PHP
<?php
function akteora_verify_signature(string $secret, string $header, string $rawBody): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
if (abs(time() - $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
In WordPress, or any PHP receiver, read the raw body from php://input:
$raw = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
if (!akteora_verify_signature(AKTEORA_WEBHOOK_SECRET, $signature, $raw)) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
http_response_code(204);
These three functions are run in Akteora's own test suite against a request the webhook worker really sent — and against a changed body and a stale timestamp, which they must refuse — so they stay correct.
Retries
A delivery that does not get a 2xx is tried again, measured from each failure:
| Attempt | After the one before |
|---|---|
| 1 | at once |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
Seven attempts over a little under 21 hours. After the seventh, the delivery is failed and is sent
again only if you replay it. Every retry carries the same body and event id, with a fresh
signature.
An endpoint that fails for 24 hours straight is switched off. "Continuous" is measured across all
of its deliveries, from the first failure after its last success; one success resets it. When it is
switched off, nothing more is sent to it, deliveries still waiting are marked failed, and the
organisation's owners and admins are emailed. Its status is then disabled; fix the receiver and
add the endpoint again. While it is failing but not yet off, status is failing and
failing_since says since when.
The delivery log
Every delivery, and every attempt within it, is kept: the headers sent (signature included), the exact body, the response status, the first kilobyte of the response body, and how long it took. It is the first place to look when something did not arrive — the dashboard shows it under Developers → Webhooks → your endpoint, and so does the API:
curl "https://api.akteora.com/v1/webhooks/whe_01K5B2E4G6J8M0P2R4T6W8Y0A2/deliveries?limit=20" \
-H "Authorization: Bearer $AKTEORA_API_KEY"
{
"data": [
{
"id": "whd_01K5B3F5H7K9N1Q3S5V7X9Z1B3",
"endpoint_id": "whe_01K5B2E4G6J8M0P2R4T6W8Y0A2",
"event_id": "evt_01K5B3F4A6C8E0G2J4M6P8R0T2",
"event_type": "submission.approved",
"status": "delivered",
"next_attempt_at": null,
"delivered_at": "2026-09-15T11:03:25.087Z",
"failure_reason": null,
"replay_of": null,
"created_at": "2026-09-15T11:02:55.100Z",
"body": "{\"id\":\"evt_01K5B3F4A6C8E0G2J4M6P8R0T2\",\"type\":\"submission.approved\", …}",
"attempts": [
{
"number": 1,
"attempted_at": "2026-09-15T11:02:55.140Z",
"request_headers": { "x-signature": "t=1757937745,v1=…", "…": "…" },
"response_status": 503,
"response_body": "<html><body><h1>503 Service Unavailable</h1></body></html>",
"duration_ms": 212,
"error": null
},
{
"number": 2,
"attempted_at": "2026-09-15T11:03:25.000Z",
"request_headers": { "x-signature": "t=1757937775,v1=…", "…": "…" },
"response_status": 200,
"response_body": "{\"received\":true}",
"duration_ms": 87,
"error": null
}
]
}
],
"next_cursor": null
}
error explains an attempt that got no response at all: No response within 10 s.,
Connection refused., The endpoint's TLS certificate was refused (CERT_HAS_EXPIRED).
Replay sends a delivery again — to fix a receiver after its retries ran out, or to reprocess an event:
curl -X POST https://api.akteora.com/v1/webhooks/deliveries/whd_01K5B3F5H7K9N1Q3S5V7X9Z1B3/replay \
-H "Authorization: Bearer $AKTEORA_API_KEY"
A replay is a new delivery (replay_of names the original) with its own attempts and the retry
schedule, carrying the identical body — the same bytes, so the same event id. A receiver that
deduplicates on id will see it as the event it already has; that is the point when you replay by
mistake, and something to allow for when you replay on purpose.
The log is kept as long as the endpoint; deleting an endpoint deletes its log.
Routes
All need the webhooks:manage scope (or an owner's or admin's session).
GET /v1/webhooks |
endpoints, newest first |
POST /v1/webhooks |
add one: { url, events[] }; the secret is in the response, once |
DELETE /v1/webhooks/{id} |
stop sending, and delete its log |
POST /v1/webhooks/{id}/test |
queue a sample event: { type? } |
GET /v1/webhooks/{id}/deliveries |
the delivery log, paginated |
POST /v1/webhooks/deliveries/{id}/replay |
send a delivery again |
An organisation can have 20 endpoints.