# Webhooks

Base URL: https://partner.rktad.com
Authentication: Authorization: Bearer YOUR_API_KEY (server-side only).
Dashboard: https://partner.rktad.com/dashboard/ → Webhooks.
OpenAPI: https://partner.rktad.com/openapi.json (Webhooks tag).

## The flow

1. Create an HTTPS endpoint on your server, then register it with POST /v1/webhooks before installing the pixel. Send a test and verify delivery first.
2. Receive session.created with session_id, tracker_id, customer_reference, custom and visitor_country. IP and email are not added automatically; custom contains only the labels your installer supplies.
3. Decide whether to reveal that session. POST /v1/sessions/{session_id}/reveal with a new UUID v4 Idempotency-Key. Reuse the same key for retries.
4. Receive reveal.completed with the frozen receipt and company/network result, including ISP, unclassified or empty results. Previously accepted pending reveals complete automatically while a completion subscription is active. A completed nonempty result records one reveal unit even if your receiver is offline. Webhook retries never create a new reveal or another unit.

Ordinary /v1/lookup calls do not produce webhook events. Subscriptions start with future events; there is no historical backfill. Changing event subscriptions does not remove already queued deliveries. Pausing stops new event capture for that endpoint and pauses queued deliveries. Resuming does not backfill the paused period. Deleting cancels pending deliveries; in-flight requests may still arrive.

## Register

POST /v1/webhooks
Content-Type: application/json

{"name":"Production","url":"https://your-app.com/webhooks","events":["session.created","reveal.completed"],"tracker_id":null}

201 returns the endpoint and signing_secret, shown once. Save it securely on your server. GET never returns it. tracker_id is optional: null means all your trackers. URL is immutable; create another endpoint to change it. Maximum five endpoints per partner. If a creation response is lost, list endpoints before retrying; rotate the existing endpoint's secret if needed.

POST /v1/webhooks/{id}/test with {} queues a synthetic webhook.test event. 202 means accepted, not delivered. Inspect GET /v1/webhooks/{id}/deliveries and GET /v1/webhooks/{id}/deliveries/{delivery_id}. Newest first,50 per page; use next_cursor when has_more=true. GET /v1/webhooks lists totals and worker health. PATCH /v1/webhooks/{id} updates name, events or enabled. DELETE /v1/webhooks/{id} cancels pending work. POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry with {} replays the same event ID before payload expiry. No additional reveal unit.

## Copy-and-paste examples

Set PARTNER_API_KEY on your server. Set WEBHOOK_ID to the id returned by creation and DELIVERY_ID to a delivery id returned by its history. Replace the example receiver hostname with your own reachable HTTPS server.

~~~sh
curl https://partner.rktad.com/v1/webhooks \
  -H "Authorization: Bearer $PARTNER_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"name":"Production","url":"https://your-app.example/webhooks","events":["session.created","reveal.completed"]}'

# Queue a test. Check its delivery; 202 alone does not mean delivered.
curl "https://partner.rktad.com/v1/webhooks/$WEBHOOK_ID/test" \
  -H "Authorization: Bearer $PARTNER_API_KEY" \
  -H 'Content-Type: application/json' --data '{}'

curl "https://partner.rktad.com/v1/webhooks/$WEBHOOK_ID/deliveries" \
  -H "Authorization: Bearer $PARTNER_API_KEY"

# Pause. Use true to resume; the paused period is not backfilled.
curl -X PATCH "https://partner.rktad.com/v1/webhooks/$WEBHOOK_ID" \
  -H "Authorization: Bearer $PARTNER_API_KEY" \
  -H 'Content-Type: application/json' --data '{"enabled":false}'

# Retry a retained delivery after enabling the endpoint.
curl "https://partner.rktad.com/v1/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/retry" \
  -H "Authorization: Bearer $PARTNER_API_KEY" \
  -H 'Content-Type: application/json' --data '{}'
~~~

Example session.created payload (illustrative IDs and values):

~~~json
{
  "id": "evt_session_00000000-0000-4000-8000-000000000001",
  "type": "session.created",
  "schema_version": 1,
  "created_at": "2026-09-25T12:00:00.000Z",
  "data": {
    "session_id": "00000000-0000-4000-8000-000000000001",
    "tracker_id": "00000000-0000-4000-8000-000000000002",
    "customer_reference": "acme",
    "custom": {
      "account_id": "acme",
      "campaign": "autumn",
      "plan": "pro"
    },
    "visitor_country": "GB",
    "first_seen_at": "2026-09-25T12:00:00.000Z",
    "last_seen_at": "2026-09-25T12:00:00.000Z",
    "expires_at": "2026-10-25T12:00:00.000Z"
  }
}
~~~

Example reveal.completed payload:

~~~json
{
  "id": "evt_reveal_00000000-0000-4000-8000-000000000003",
  "type": "reveal.completed",
  "schema_version": 1,
  "created_at": "2026-09-25T12:00:00.000Z",
  "data": {
    "reveal_id": "00000000-0000-4000-8000-000000000003",
    "session_id": "00000000-0000-4000-8000-000000000001",
    "tracker_id": "00000000-0000-4000-8000-000000000002",
    "custom": {
      "account_id": "acme",
      "campaign": "autumn",
      "plan": "pro"
    },
    "status": "complete",
    "billable": true,
    "billable_units": 1,
    "created_at": "2026-09-25T12:00:00.000Z",
    "completed_at": "2026-09-25T12:00:00.000Z",
    "expires_at": "2026-10-25T12:00:00.000Z",
    "result": {
      "status": "complete",
      "match_type": "company",
      "company_found": true,
      "enriched_at": "2026-09-18T12:00:00.000Z",
      "stale": false,
      "fresh_until": "2026-10-18T12:00:00.000Z",
      "refresh_status": "not_needed",
      "refresh_error": null,
      "network": {
        "connection_type": "Business",
        "audience_type": "Business",
        "audience_group": null,
        "detail_level": null,
        "is_isp": false
      },
      "company": {
        "name": "Example Company",
        "website": "example.com",
        "brand_name": null,
        "linkedin_url": null,
        "industry": "Software",
        "industry_subcategory": null,
        "employees": "120",
        "annual_revenue": null,
        "revenue_band": null,
        "naics_code": "541511",
        "sic_code": null,
        "city": "London",
        "region": null,
        "postal_code": null,
        "country_code": "GB",
        "country_name": "United Kingdom"
      }
    },
    "status_url": "/v1/reveals/00000000-0000-4000-8000-000000000003"
  }
}
~~~

custom is the same first-visit snapshot in both events and the API receipt. It is not trusted account ownership. Older queued events may omit custom; treat omission as {}. See /docs#custom to install these labels.

## Verify every delivery

Headers: webhook-id, webhook-timestamp (Unix seconds), webhook-signature.
Signature: v1,BASE64_HMAC_SHA256. During secret rotation the header contains two space-separated signatures. Decode the base64 after whsec_ to get the signing key. Sign the exact string ID.TIMESTAMP.RAW_BODY. Do not parse and reserialize JSON before verification. Check a five-minute timestamp window and use a constant-time comparison. Treat malformed or duplicate headers as invalid. Keep your server clock synchronized.

~~~js
import {createHmac, timingSafeEqual} from 'node:crypto';
function verifyWebhook(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (typeof id !== 'string' || !/^evt_[a-zA-Z0-9_-]{1,100}$/.test(id) ||
      typeof timestamp !== 'string' || !/^\d{10,11}$/.test(timestamp) ||
      Math.abs(Date.now() / 1000 - Number(timestamp)) > 300 ||
      typeof signatures !== 'string' || signatures.length > 512) return false;
  const expected = createHmac('sha256', Buffer.from(secret.slice(6), 'base64'))
    .update(id + '.' + timestamp + '.').update(rawBody).digest();
  return signatures.split(' ').some(part => {
    if (!/^v1,[A-Za-z0-9+/]{43}=$/.test(part)) return false;
    const actual = Buffer.from(part.slice(3), 'base64');
    return actual.length === expected.length && timingSafeEqual(actual, expected);
  });
}
~~~

Enforce a bounded receiver body limit (1MiB is sufficient for current event payloads). After verification, check event.id equals webhook-id. Atomically store the event and its ID in your own durable queue, then return 200 or204 promptly. Return2xx for an already stored duplicate too. Process business logic asynchronously. Never trust webhook payloads without signature validation.

## Delivery guarantees and retries

At least once, not exactly once. Events may arrive more than once or out of order. Deduplicate by event.id, not session ID: a session can have multiple separately billed reveals. Retries and manual redelivery preserve event ID and payload; timestamp and signature are refreshed for each attempt. A timeout after your server stored the event can cause another delivery.

Public HTTPS port443 only. TLS certificates must be valid. Private/loopback/link-local/reserved addresses, this platform's origin, mixed public/private DNS answers and redirects are rejected. All destination IPs are checked on every attempt. No receiver response body is retained.

Any2xx acknowledges. Other HTTP statuses, DNS/TLS/connect errors and10-second timeouts retry with exponential delay (about15seconds initially, capped at6hours, with jitter). Retry-After is respected up to24hours. After12 attempts or7days per retry cycle, failed deliveries stay visible. Manual retry starts another cycle while the source payload remains available. Pending includes retrying, paused and in-flight deliveries. Failed, expired and cancelled are separate states.

Payloads expire with their session or receipt, at most30days from session creation. A late reveal may have less time left. Expired payloads cannot be replayed; minimal delivery metadata remains90days. Operational cleanup is incremental. One endpoint may have up to32 concurrent deliveries and200 delivery starts/second; these are ceilings, not guaranteed throughput. Slow receivers reduce delivery speed. Queue capacity is2,500,000 pending deliveries per partner. A shared retained-payload storage limit can also temporarily stop new events; each event is limited to1MiB. When full, source capture/reveal completion is rejected with503 and is not acknowledged as successful; retry safely. A queued accepted reveal remains pending until completion can commit. Pause does not free queue slots.

POST /v1/webhooks/{id}/rotate-secret with {} returns a new signing_secret once. Both signatures are sent for24hours, then only the new one. A second rotation during this overlap returns409. On a lost rotation response, use the existing secret during the overlap, then rotate again after the overlap; inspect secret_rotation_until and do not assume the lost new secret can be recovered.

## Errors and accounting

400 invalid_webhook / invalid_webhook_url / invalid_webhook_query: fix request.
401 unauthorized: check API key.
404 webhook_not_found / webhook_delivery_not_found / tracker_not_found: wrong ID or account.
409 webhook_disabled / webhook_rotation_pending: enable endpoint or wait for overlap.
410 webhook_event_expired: payload no longer available.
429 webhook_limit / webhook_management_limit / rate_limit / api_rate_limit: follow Retry-After. Management budget120/minute per partner per service, separate from lookup allowance.
503 webhook_queue_full / webhooks_unavailable / service_unavailable: retry with backoff; do not treat as delivered.

Creation, tests, delivery reads, configuration changes and redelivery do not count lookup requests or reveal units. Completing an already authorized reveal follows the existing per-reveal billing rule. Delivery success is not a condition for billing. No webhook automatically starts a new reveal.
