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 again
endpoint = 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 again
let 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 again

Omit 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's signing_secret, hex-encoded.

To verify a delivery:

  1. Read the raw request body as bytes — do not re-serialize parsed JSON; a re-encode changes the bytes and breaks the HMAC.
  2. Recompute HMAC_SHA256(signing_secret, "{t}.{raw_body}") and compare, in constant time, to v1.
  3. Reject if |now − t| > 300 seconds (5-minute tolerance) to stop replay.
  4. 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| <= 300
import { 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 2xx within 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 event id (evt_…) is stable across retries and redeliveries. Record processed ids and skip repeats.
  • Don't assume order. An evaluation.completed can, in principle, arrive before an evaluation.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);
}

On this page