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
{
"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"
}// 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();
// });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 "", 200Server-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.
{
"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
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
// 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)
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 "", 200Verification — 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)
}