Webhooks

Receive signed terminal-state callbacks

Use webhooks to move production integrations from polling to event-driven task completion while verifying each delivery with HMAC.

RESTBearer authJSON responses

Webhook examples

Payload
{
  "event": "swap.completed",
  "id": "tsk_01HXYZ...",
  "type": "image",
  "status": "done",
  "credit_cost": 1,
  "progress": 100,
  "created_at": "2026-05-12T18:42:11Z",
  "result_url": "https://r2.aifacesswap.com/swaps/tsk_01HXYZ.jpg",
  "error": null,
  "metadata": { "my_request_id": "abc123" },
  "webhook_url": "https://example.com/webhooks/aifaceswap"
}
Node verify
// Express-style handler. Reads the RAW body so the HMAC matches byte-for-byte.
import crypto from "node:crypto";

const SECRET = process.env.AIFS_API_KEY; // = your API key (v1 signs webhooks with the API key itself; no separate webhook secret)
const MAX_AGE_SECONDS = 300;

function timingSafeEqualHex(a, b) {
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
}

function verify(rawBody, header) {
  if (!header) return false;
  const match = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header);
  if (!match) return false;
  const ts = Number(match[1]);
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > MAX_AGE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  return timingSafeEqualHex(match[2], expected);
}

// app.post("/webhooks/aifaceswap", express.raw({ type: "*/*" }), (req, res) => {
//   const ok = verify(req.body.toString("utf8"), req.header("x-aifaceswap-signature"));
//   if (!ok) return res.status(401).end();
//   const payload = JSON.parse(req.body.toString("utf8"));
//   // handle payload.event === "swap.completed" | "swap.failed"
//   res.status(200).end();
// });
Python verify
import hmac, hashlib, re, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["AIFS_API_KEY"]  # = your API key (v1 signs webhooks with the API key itself; no separate webhook secret)
MAX_AGE_SECONDS = 300
HEADER_RE = re.compile(r"^t=(\d+),v1=([a-f0-9]+)$")

def verify(raw_body: bytes, header: str | None) -> bool:
    if not header:
        return False
    m = HEADER_RE.match(header)
    if not m:
        return False
    ts = int(m.group(1))
    if abs(int(time.time()) - ts) > MAX_AGE_SECONDS:
        return False
    signed = f"{ts}.{raw_body.decode('utf-8')}".encode()
    expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(m.group(2), expected)

@app.post("/webhooks/aifaceswap")
def webhook():
    raw = request.get_data()  # raw bytes — do NOT use request.json here
    if not verify(raw, request.headers.get("x-aifaceswap-signature")):
        abort(401)
    payload = request.get_json()
    # handle payload["event"] in {"swap.completed", "swap.failed"}
    return "", 200

Server-side only. Keep API keys out of client bundles.

Webhooks

Pass webhook_url on POST /api/v1/swaps and we POST a JSON body once the swap reaches a terminal state. Deliveries retry on non-2xx responses; treat your handler as idempotent.

Payload shape

Flat object: { event, ...PublicSwap }. event is one of swap.completed or swap.failed.

JSON
{
  "event": "swap.completed",
  "id": "tsk_01HXYZ...",
  "type": "image",
  "status": "done",
  "credit_cost": 1,
  "progress": 100,
  "created_at": "2026-05-12T18:42:11Z",
  "result_url": "https://r2.aifacesswap.com/swaps/tsk_01HXYZ.jpg",
  "error": null,
  "metadata": { "my_request_id": "abc123" },
  "webhook_url": "https://example.com/webhooks/aifaceswap"
}

Signature header

Code
x-aifaceswap-signature: t=<unix-seconds>,v1=<hex-sha256-hmac>

The signing key is the API key itself. The signed input is `${t}.${rawBody}` — verify with the raw bytes.

Verification — Node

Node.js
// Express-style handler. Reads the RAW body so the HMAC matches byte-for-byte.
import crypto from "node:crypto";

const SECRET = process.env.AIFS_API_KEY; // = your API key (v1 signs webhooks with the API key itself; no separate webhook secret)
const MAX_AGE_SECONDS = 300;

function timingSafeEqualHex(a, b) {
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
}

function verify(rawBody, header) {
  if (!header) return false;
  const match = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header);
  if (!match) return false;
  const ts = Number(match[1]);
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > MAX_AGE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  return timingSafeEqualHex(match[2], expected);
}

// app.post("/webhooks/aifaceswap", express.raw({ type: "*/*" }), (req, res) => {
//   const ok = verify(req.body.toString("utf8"), req.header("x-aifaceswap-signature"));
//   if (!ok) return res.status(401).end();
//   const payload = JSON.parse(req.body.toString("utf8"));
//   // handle payload.event === "swap.completed" | "swap.failed"
//   res.status(200).end();
// });

Verification — Python (Flask)

Python
import hmac, hashlib, re, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["AIFS_API_KEY"]  # = your API key (v1 signs webhooks with the API key itself; no separate webhook secret)
MAX_AGE_SECONDS = 300
HEADER_RE = re.compile(r"^t=(\d+),v1=([a-f0-9]+)$")

def verify(raw_body: bytes, header: str | None) -> bool:
    if not header:
        return False
    m = HEADER_RE.match(header)
    if not m:
        return False
    ts = int(m.group(1))
    if abs(int(time.time()) - ts) > MAX_AGE_SECONDS:
        return False
    signed = f"{ts}.{raw_body.decode('utf-8')}".encode()
    expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(m.group(2), expected)

@app.post("/webhooks/aifaceswap")
def webhook():
    raw = request.get_data()  # raw bytes — do NOT use request.json here
    if not verify(raw, request.headers.get("x-aifaceswap-signature")):
        abort(401)
    payload = request.get_json()
    # handle payload["event"] in {"swap.completed", "swap.failed"}
    return "", 200

Verification — Go

Go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"time"
)

var (
	headerRE      = regexp.MustCompile(`^t=(\d+),v1=([a-f0-9]+)$`)
	secret        = []byte(os.Getenv("AIFS_API_KEY")) // = your API key (v1 signs webhooks with the API key itself; no separate webhook secret)
	maxAgeSeconds = int64(300)
)

func verify(rawBody []byte, header string) bool {
	m := headerRE.FindStringSubmatch(header)
	if m == nil {
		return false
	}
	ts, err := strconv.ParseInt(m[1], 10, 64)
	if err != nil {
		return false
	}
	if abs(time.Now().Unix()-ts) > maxAgeSeconds {
		return false
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(strconv.FormatInt(ts, 10) + "." + string(rawBody)))
	expected := hex.EncodeToString(mac.Sum(nil))
	got, err := hex.DecodeString(m[2])
	if err != nil {
		return false
	}
	want, _ := hex.DecodeString(expected)
	return hmac.Equal(got, want)
}

func abs(x int64) int64 {
	if x < 0 {
		return -x
	}
	return x
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}
	if !verify(raw, r.Header.Get("x-aifaceswap-signature")) {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	// Decode raw into your payload struct; handle event swap.completed / swap.failed.
	w.WriteHeader(http.StatusOK)
}