Webhooks
Register endpoints, verify the X-Airtype-Signature HMAC, handle retries and rotation, and deduplicate deliveries.
Webhooks are the intended way to learn about outcomes. You register an HTTPS endpoint; AirCredit
POSTs each event to it as it happens. The delivery body is the canonical event envelope —
byte-for-byte the same object you can read back from list_events / get_event.
Managing endpoints
Create an endpoint with an HTTPS URL. The response is the only place the signing_secret is
ever returned — capture it immediately. Each API client may have at most 20 active endpoints.
curl -X POST https://api.aircredit.de/partner/v1/webhook_endpoints \
-H "Authorization: Bearer $AIRCREDIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example-partner.com/aircredit/webhooks",
"description": "prod receiver",
"events": ["file.ready", "file.failed", "evaluation.completed"]
}'const endpoint = await client.createWebhookEndpoint({
url: "https://example-partner.com/aircredit/webhooks",
description: "prod receiver",
events: ["file.ready", "file.failed", "evaluation.completed"],
});
await saveSecret(endpoint.id, endpoint.signingSecret); // never returned againendpoint = client.create_webhook_endpoint(
url="https://example-partner.com/aircredit/webhooks",
description="prod receiver",
events=["file.ready", "file.failed", "evaluation.completed"],
)
save_secret(endpoint.id, endpoint.signing_secret) # never returned againlet endpoint = client.create_webhook_endpoint()
.body_map(|b| b
.url("https://example-partner.com/aircredit/webhooks")
.description(Some("prod receiver".into()))
.events(Some(vec!["file.ready".into(), "file.failed".into(), "evaluation.completed".into()])))
.send().await?.into_inner();
save_secret(&endpoint.id, endpoint.signing_secret.as_deref())?; // never returned againOmit events (or send null) to subscribe to all current and future event types — new types
then arrive automatically. Provide a list to receive only those types. URLs must be https://;
plain http:// is allowed only for loopback addresses (127.0.0.1, ::1, localhost) so you can
test against a local receiver. Deleting an endpoint removes it from listings but keeps its delivery
history readable via the events API.
Endpoints are editable in place with update_webhook_endpoint: change the URL, description, or
event filter, or set status to disabled to pause deliveries during maintenance and back to
active to resume. Events emitted while an endpoint is disabled are not delivered to it later —
after resuming, reconcile via list_events or redeliver what you missed.
The signature scheme
Every delivery carries an X-Airtype-Signature header:
X-Airtype-Signature: t=1706783524,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t— the Unix timestamp (seconds) when the signature was generated.v1— the signature: HMAC-SHA256 over the exact string"{t}.{raw request body}", keyed with the endpoint'ssigning_secret, hex-encoded.
To verify a delivery:
- Read the raw request body as bytes — do not re-serialize parsed JSON; a re-encode changes the bytes and breaks the HMAC.
- Recompute
HMAC_SHA256(signing_secret, "{t}.{raw_body}")and compare, in constant time, tov1. - Reject if
|now − t| > 300seconds (5-minute tolerance) to stop replay. - During secret rotation the header may carry multiple
v1=values — accept if any matches.
Verifying a signature
# Verification is done in code, not curl. Conceptually:
# signed = "${t}.$(cat raw_body)"
# expected = HMAC_SHA256(signing_secret, signed) as hex
# accept if expected == v1 AND |now - t| <= 300import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map(kv => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
// Header may carry multiple v1= values during rotation; accept if any matches.
return header
.split(",")
.filter(kv => kv.startsWith("v1="))
.some(kv => {
const v1 = kv.slice(3);
return (
v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
);
});
}import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > 300:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# Header may carry multiple v1= values during rotation; accept if any matches.
return any(
hmac.compare_digest(kv[3:], expected)
for kv in header.split(",")
if kv.startswith("v1=")
)use hmac::{Hmac, Mac};
use sha2::Sha256;
fn verify(raw_body: &[u8], header: &str, secret: &[u8], now: u64) -> bool {
let mut t = None;
let mut sigs = Vec::new();
for kv in header.split(',') {
match kv.split_once('=') {
Some(("t", v)) => t = v.parse::<u64>().ok(),
Some(("v1", v)) => sigs.push(v),
_ => {}
}
}
let Some(t) = t else { return false };
if now.abs_diff(t) > 300 {
return false;
}
let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("hmac key");
mac.update(format!("{t}.").as_bytes());
mac.update(raw_body);
let expected = hex::encode(mac.finalize().into_bytes());
// Header may carry multiple v1= values during rotation; accept if any matches.
sigs.iter().any(|v1| {
v1.len() == expected.len()
&& v1.bytes().zip(expected.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
})
}Caution
Verify against the raw bytes of the request, before any JSON parsing. Frameworks that
auto-parse the body (Express json(), FastAPI models) hand you a re-serialized object whose bytes
differ from what was signed. Capture the raw body first.
Responding and retries
- Acknowledge with any
2xxwithin 30 seconds. Anything else — a non-2xx, a timeout, a connection error — counts as a failed delivery. - Retry schedule: failed deliveries are retried after 1m, 5m, 30m, 2h, 12h, and 24h, then abandoned. Make your receiver idempotent; a slow-but-eventually-200 endpoint may still get retried.
Rotating the secret
Rotate an endpoint's signing secret whenever you suspect exposure — or on a schedule:
curl -X POST https://api.aircredit.de/partner/v1/webhook_endpoints/$ENDPOINT_ID/secret/rotations \
-H "Authorization: Bearer $AIRCREDIT_API_KEY"const rotation = await client.rotateWebhookEndpointSecret({ endpointId });
// Verify against BOTH secrets until the grace window ends, then drop the old one.
await saveSecret(endpointId, rotation.signingSecret);
console.log("old secret valid until", rotation.previousSecretExpiresAt);rotation = client.rotate_webhook_endpoint_secret(endpoint_id=endpoint_id)
# Verify against BOTH secrets until the grace window ends, then drop the old one.
save_secret(endpoint_id, rotation.signing_secret)
print("old secret valid until", rotation.previous_secret_expires_at)let rotation = client.rotate_webhook_endpoint_secret()
.endpoint_id(&endpoint_id)
.send().await?.into_inner();
// Verify against BOTH secrets until the grace window ends, then drop the old one.
save_secret(&endpoint_id, &rotation.signing_secret)?;
println!("old secret valid until {}", rotation.previous_secret_expires_at);During the 24-hour grace window, every delivery is signed with both the old and the new secret,
so the header carries multiple v1= values. A verifier that accepts any matching v1= value
(rule 4 above) keeps working through the rotation with zero downtime: store the new secret when you
rotate, verify against both until previous_secret_expires_at, then drop the old one.
Ordering and deduplication
Deliveries may arrive out of order and, rarely, more than once. Two rules:
- Deduplicate on
id. The eventid(evt_…) is stable across retries and redeliveries. Record processed ids and skip repeats. - Don't assume order. An
evaluation.completedcan, in principle, arrive before anevaluation.progress. Treat each event as a fact about state, not a step in a sequence; reconcile against the resource if order matters.
Inspecting deliveries
Every event and its per-endpoint delivery timeline is readable over the events API — useful when a
receiver was down and you want to see what was attempted. Filter list_events by type or by
submission_id to trace one case end to end.
# List recent events, newest first
curl "https://api.aircredit.de/partner/v1/events?type=evaluation.completed" \
-H "Authorization: Bearer $AIRCREDIT_API_KEY"
# One event with its full delivery/attempt history
curl https://api.aircredit.de/partner/v1/events/$EVENT_ID \
-H "Authorization: Bearer $AIRCREDIT_API_KEY"const page = await client.listEvents({ type: "evaluation.completed" });
const detail = await client.getEvent({ eventId: page.data[0].id });
for (const d of detail.deliveries) {
console.log(d.endpointUrl, d.deliveryStatus, d.attemptCount);
}page = client.list_events(type="evaluation.completed")
detail = client.get_event(event_id=page.data[0].id)
for d in detail.deliveries:
print(d.endpoint_url, d.delivery_status, d.attempt_count)let page = client.list_events().type_("evaluation.completed").send().await?.into_inner();
let detail = client.get_event().event_id(&page.data[0].id).send().await?.into_inner();
for d in &detail.deliveries {
println!("{} {:?} {}", d.endpoint_url, d.delivery_status, d.attempt_count);
}Events are retained for 30 days. See the API reference for the full
Event, EventDelivery, and EventDeliveryAttempt schemas.
Redelivering an event
After an outage on your side, requeue what was missed instead of reconstructing state by hand.
redeliver_event requeues every non-succeeded delivery of the event (or one specific endpoint's
delivery when you pass endpoint_id); new attempts append to the existing timeline.
curl -X POST https://api.aircredit.de/partner/v1/events/$EVENT_ID/redeliver \
-H "Authorization: Bearer $AIRCREDIT_API_KEY"const detail = await client.redeliverEvent({ eventId: "evt_2f0d9c62-…" });
for (const delivery of detail.deliveries) {
console.log(delivery.endpointUrl, delivery.deliveryStatus);
}detail = client.redeliver_event(event_id="evt_2f0d9c62-…")
for delivery in detail.deliveries:
print(delivery.endpoint_url, delivery.delivery_status)let detail = client.redeliver_event()
.event_id("evt_2f0d9c62-…")
.send().await?.into_inner();
for delivery in &detail.deliveries {
println!("{} {:?}", delivery.endpoint_url, delivery.delivery_status);
}