Skip to content
Webhooks

Webhook Integration

Event Types

Aiffinity sends webhook events to your configured endpoint when significant platform events occur. Subscribe to specific event types when configuring your webhook profile, or use ["*"] to receive all events.

Event TypeCategoryDescription
package.submittedPackageA package version has been submitted for review
package.publishedPackageA package version has been approved and published to the catalog
package.suspendedPackageA package has been suspended due to policy violation or inactivity
install.createdInstallA user has installed your package
install.removedInstallA user has uninstalled your package
capability.invokedCapabilityA capability was successfully invoked for a user
capability.failedCapabilityA capability invocation failed (timeout, error, health check failure)

Payload Schemas

Every webhook delivery is an HTTP POST with a JSON body. All payloads share a common envelope structure with event-specific data in the data field.

Common Envelope

{
  "id": "evt_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "type": "package.published",
  "created_at": "2026-04-04T10:30:00Z",
  "app_id": "app_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "idempotency_key": "idem_abc123def456",
  "data": { ... }
}

package package.submitted

Fired when a package version is submitted for review.

{
  "id": "evt_pkg_sub_001",
  "type": "package.submitted",
  "created_at": "2026-04-04T10:30:00Z",
  "app_id": "app_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "idempotency_key": "idem_pkg_sub_001",
  "data": {
    "package_id": "pkg_acme_weather",
    "version_id": "ver_1_0_0",
    "version": "1.0.0",
    "name": "Acme Weather Widgets",
    "risk_tier": "data_only",
    "submitted_by": "[email protected]"
  }
}

package package.published

Fired when a package version passes review and is published to the catalog.

{
  "id": "evt_pkg_pub_001",
  "type": "package.published",
  "created_at": "2026-04-04T11:00:00Z",
  "app_id": "app_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "idempotency_key": "idem_pkg_pub_001",
  "data": {
    "package_id": "pkg_acme_weather",
    "version_id": "ver_1_0_0",
    "version": "1.0.0",
    "name": "Acme Weather Widgets",
    "catalog_url": "https://aiffinity.me/providers/acme-weather-widgets"
  }
}

package package.suspended

Fired when a package is suspended. Includes the reason and any remediation steps.

{
  "id": "evt_pkg_sus_001",
  "type": "package.suspended",
  "created_at": "2026-04-04T15:00:00Z",
  "app_id": "app_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "idempotency_key": "idem_pkg_sus_001",
  "data": {
    "package_id": "pkg_acme_weather",
    "reason": "health_check_failures",
    "message": "Health check endpoint returned 503 for 72 consecutive hours",
    "suspended_at": "2026-04-04T15:00:00Z",
    "remediation": "Restore your health check endpoint and contact support to reinstate."
  }
}

install install.created

Fired when a user installs your package.

{
  "id": "evt_inst_cre_001",
  "type": "install.created",
  "created_at": "2026-04-04T12:00:00Z",
  "app_id": "app_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "idempotency_key": "idem_inst_cre_001",
  "data": {
    "install_id": "inst_usr_abc123",
    "user_id": "usr_def456",
    "package_id": "pkg_acme_weather",
    "version": "1.0.0",
    "scopes_granted": ["weather:read", "forecast:read"]
  }
}

install install.removed

Fired when a user uninstalls your package. Clean up any user-specific resources.

{
  "id": "evt_inst_rem_001",
  "type": "install.removed",
  "created_at": "2026-04-04T13:00:00Z",
  "app_id": "app_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "idempotency_key": "idem_inst_rem_001",
  "data": {
    "install_id": "inst_usr_abc123",
    "user_id": "usr_def456",
    "package_id": "pkg_acme_weather",
    "reason": "user_initiated"
  }
}

capability capability.invoked

Fired after a successful capability execution. Useful for analytics and usage tracking.

{
  "id": "evt_cap_inv_001",
  "type": "capability.invoked",
  "created_at": "2026-04-04T14:00:00Z",
  "app_id": "app_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "idempotency_key": "idem_cap_inv_001",
  "data": {
    "capability_name": "current_weather",
    "mode": "state",
    "user_id": "usr_def456",
    "install_id": "inst_usr_abc123",
    "request_id": "req_abc123",
    "duration_ms": 245,
    "status": "ok"
  }
}

capability capability.failed

Fired when a capability invocation fails. Includes the error code and whether retries were exhausted.

{
  "id": "evt_cap_fail_001",
  "type": "capability.failed",
  "created_at": "2026-04-04T14:05:00Z",
  "app_id": "app_01J8XKWM3N4QR5STVYZ2BCDEFG",
  "idempotency_key": "idem_cap_fail_001",
  "data": {
    "capability_name": "current_weather",
    "mode": "state",
    "user_id": "usr_def456",
    "install_id": "inst_usr_abc123",
    "request_id": "req_def456",
    "error_code": "UPSTREAM_UNAVAILABLE",
    "error_message": "Weather API returned 503",
    "retries_exhausted": true,
    "duration_ms": 10042
  }
}

Signature Verification

Every webhook delivery includes an X-Aiffinity-Signature header containing an HMAC-SHA256 signature of the raw request body. You must verify this signature before processing any event to ensure the request originated from Aiffinity and was not tampered with.

How it works

  1. Aiffinity computes HMAC-SHA256(webhook_secret, raw_request_body)
  2. The hex-encoded digest is sent as: X-Aiffinity-Signature: sha256=<hex_digest>
  3. Your server re-computes the HMAC using the same secret and compares it to the header value
  4. Use a constant-time comparison to prevent timing attacks

TypeScript Implementation

import { createHmac, timingSafeEqual } from 'node:crypto';
import type { IncomingMessage } from 'node:http';

const WEBHOOK_SECRET = process.env.AIFFINITY_WEBHOOK_SECRET!;

function verifySignature(rawBody: Buffer, signatureHeader: string): boolean {
  // The header format is: sha256=<hex_digest>
  const expectedPrefix = 'sha256=';
  if (!signatureHeader.startsWith(expectedPrefix)) {
    return false;
  }

  const receivedDigest = signatureHeader.slice(expectedPrefix.length);
  const computedDigest = createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  // Constant-time comparison to prevent timing attacks
  const receivedBuffer = Buffer.from(receivedDigest, 'hex');
  const computedBuffer = Buffer.from(computedDigest, 'hex');

  if (receivedBuffer.length !== computedBuffer.length) {
    return false;
  }

  return timingSafeEqual(receivedBuffer, computedBuffer);
}

// Express middleware example
import express from 'express';

const app = express();

app.post(
  '/webhooks/aiffinity',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-aiffinity-signature'] as string;

    if (!signature || !verifySignature(req.body, signature)) {
      console.error('Webhook signature verification failed');
      return res.status(401).json({ error: 'Invalid signature' });
    }

    const event = JSON.parse(req.body.toString());

    // Process the event
    switch (event.type) {
      case 'install.created':
        console.log(`User ${event.data.user_id} installed package`);
        break;
      case 'install.removed':
        console.log(`User ${event.data.user_id} removed package`);
        break;
      case 'capability.failed':
        console.error(`Capability ${event.data.capability_name} failed: ${event.data.error_code}`);
        break;
      default:
        console.log(`Received event: ${event.type}`);
    }

    // Respond with 200 to acknowledge receipt
    res.status(200).json({ received: true });
  }
);

Important: You must parse the raw request body for signature verification. If you use express.json() before verifying, the re-serialized JSON may differ from the original payload, causing verification to fail. Use express.raw({ type: 'application/json' }) as shown above.

Retry Policy

If your endpoint does not respond with a 2xx status code within the configured timeout (default: 5 seconds), Aiffinity retries the delivery with exponential backoff.

1
Attempt 1 — Immediate delivery. Timeout: 5 seconds.
2
Retry 1 — After ~1 minute. Exponential backoff with jitter.
3
Retry 2 — After ~5 minutes from first attempt.
4
Retry 3 — After ~30 minutes from first attempt.
5
Retry 4 — After ~2 hours from first attempt.
6
Retry 5 (final) — After ~12 hours from first attempt. If this fails, the event is marked as permanently failed.

Summary:

ParameterValue
Maximum retries5
Backoff strategyExponential with jitter
Maximum elapsed time24 hours
Request timeout5 seconds (configurable up to 30s)
Expected responseAny 2xx status code

After all retries are exhausted, the failed delivery appears in the webhook delivery log in your developer console. You can manually replay any failed delivery from the console UI.

Testing Webhooks

The Aiffinity developer console provides tools for testing your webhook integration before going live.

Console UI

Navigate to your app's Webhooks tab at dev.aiffinity.me to:

CLI Testing

# Send a test webhook event
npx @aiffinity/provider-platform-sdk webhook test \
  --app-id $APP_ID \
  --event install.created

# List recent webhook deliveries
npx @aiffinity/provider-platform-sdk webhook deliveries \
  --app-id $APP_ID \
  --limit 20

# Replay a failed delivery
npx @aiffinity/provider-platform-sdk webhook replay \
  --app-id $APP_ID \
  --delivery-id del_abc123

Local Development

For local development, use a tunnel service to expose your local server:

# Start your local webhook handler
node handler.js  # listening on port 3000

# Expose it via a tunnel (e.g. ngrok, cloudflared)
ngrok http 3000

# Update your app's webhook URL to the tunnel URL
curl -X PATCH https://api.aiffinity.me/v1/providers/runtime/apps/$APP_ID \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "webhook_url": "https://abc123.ngrok.io/webhooks/aiffinity" }'

Best Practices

Idempotency

Every webhook event includes an idempotency_key field. Store processed keys and check for duplicates before processing. Due to retries and at-least-once delivery, your endpoint may receive the same event more than once.

// Example: check idempotency before processing
async function handleWebhook(event: WebhookEvent): Promise<void> {
  const alreadyProcessed = await db.webhookEvents.findByIdempotencyKey(
    event.idempotency_key
  );

  if (alreadyProcessed) {
    console.log(`Skipping duplicate event: ${event.idempotency_key}`);
    return;
  }

  // Process the event
  await processEvent(event);

  // Mark as processed
  await db.webhookEvents.insert({
    idempotency_key: event.idempotency_key,
    event_type: event.type,
    processed_at: new Date(),
  });
}

Timeout Handling

Your endpoint must respond within the configured timeout (default: 5 seconds). If your event processing takes longer:

Response Codes

Status CodeMeaningAiffinity Behavior
200 / 202SuccessEvent marked as delivered
301 / 302RedirectNot followed; treated as failure and retried
400Bad requestNo retry (your endpoint explicitly rejected the event)
401 / 403Auth errorNo retry (signature verification should handle this)
404Not foundRetried (endpoint may be temporarily misconfigured)
429Rate limitedRetried with backoff, respecting Retry-After header
500 / 502 / 503Server errorRetried with exponential backoff
TimeoutNo response in timeRetried with exponential backoff

Security Recommendations