Akteora API docs

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"
  }
}

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:

  1. 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.
  2. 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.
  3. 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.