Predict.aiDocs
Ingestion

Webhooks

Let external systems push data in — per-source URLs, HMAC-SHA256 signatures, and events vs signal delivery.

Webhooks are the push half of ingestion: instead of the platform polling, an external provider POSTs deliveries to a per-source URL. There's no schedule and no job — data arrives when the provider sends it.

A webhook source exposes two delivery URLs:

URLDelivers to
POST /v1/ingestion/webhooks/{source_id}/eventsThe event stream — free-text occurrences with an occurred_at (this is also the default for the bare /{source_id} URL)
POST /v1/ingestion/webhooks/{source_id}/signalSignal time series — each scalar field becomes a signal keyed stream.field

See events vs signals for when to use which.

Create a webhook source

curl -X POST "$API_BASE/v1/ingestion/connect/create-event" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Workspace-Id: $WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "push",
    "name": "Checkout alerts",
    "event_config": {
      "stream": "checkout_alerts",
      "array_path": "events",
      "text_path": "message",
      "occurred_at_path": "occurred_at",
      "id_path": "id"
    }
  }'
{
  "data": {
    "status": "created",
    "source_id": "9a4e77b1-…",
    "kind": "event",
    "mode": "push",
    "stream": "checkout_alerts",
    "job_ids": [],
    "webhook_url_events": "https://api.example.com/v1/ingestion/webhooks/9a4e77b1-…/events",
    "webhook_url_signal": "https://api.example.com/v1/ingestion/webhooks/9a4e77b1-…/signal",
    "webhook_url": "https://api.example.com/v1/ingestion/webhooks/9a4e77b1-…/events",
    "webhook_secret": "kX0v…",
    "message": "Webhook source created. Use the /events and /signal URLs with the signing secret."
  }
}

Save webhook_secret — it's the HMAC signing key, generated once at create time.

Request body

FieldMeaning
mode requiredpush for a webhook URL. (poll creates a scheduled REST poll instead — see the callout below.)
name optionalDisplay name for the source.
event_config optionalHow the receiver reads each delivery — fields below.

The event_config fields:

FieldMeaning
stream optionalName of the event stream / signal key prefix. Defaults to events.
array_path optionalDot/bracket path to the list of items in the payload (e.g. data.items). Default: the whole body is one item.
text_path optionalPath to the event text within an item. Defaults to text.
occurred_at_path optionalPath to when it happened — epoch seconds, epoch ms, or ISO-8601. Defaults to occurred_at; missing means "now".
id_path optionalPath to the provider's ID, used to deduplicate redeliveries.

The same endpoint with "mode": "poll" creates the other kind of event source — a scheduled REST poll instead of a push URL. See OAuth connectors for providers that set one up automatically.

Sign and send a delivery

Delivery URLs are not bearer-authenticated — the caller is an external system. Each delivery is verified instead by an HMAC-SHA256 signature of the raw request body, computed with the source's webhook_secret.

The receiver accepts the hex digest in any of these headers, with or without a sha256= prefix:

  • X-Signature
  • X-Hub-Signature-256 (GitHub-style)
  • X-Webhook-Signature
BODY='{"events":[{"id":"evt_9001","message":"Checkout failed for order 1042","occurred_at":"2026-07-15T09:30:00Z"}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')

curl -X POST "$API_BASE/v1/ingestion/webhooks/$SOURCE_ID/events" \
  -H "Content-Type: application/json" \
  -H "X-Signature: sha256=$SIG" \
  -d "$BODY"
{ "data": { "status": "ok", "kind": "events", "accepted": 1 } }

Sign the bytes you actually send — any re-serialization (key reordering, whitespace changes) after signing invalidates the signature.

A source with no signing secret rejects all deliveries unless it explicitly opts out by setting allow_unsigned: true in its config — unsigned ingestion is never the silent default.

Events delivery

Each item in the payload becomes one event. The text at text_path is required — items with empty text are skipped. occurred_at is coerced from epoch seconds, epoch milliseconds, or ISO-8601; when missing, the arrival time is used. If id_path is set, the provider's ID is kept as an external ID and redelivered items with an already-processed ID are silently dropped, so provider retries never duplicate events.

accepted counts the items that passed these checks. A delivery is capped at 1000 items; extra items are ignored.

Signal delivery

POST the same kind of payload to the /signal URL and each item's scalar fields become signal values instead:

BODY='{"readings":[{"device":"sensor-12","recorded_at":"2026-07-15T09:30:00Z","temperature":21.4,"humidity":0.63}]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')

curl -X POST "$API_BASE/v1/ingestion/webhooks/$SOURCE_ID/signal" \
  -H "Content-Type: application/json" \
  -H "X-Signature: sha256=$SIG" \
  -d "$BODY"
{ "data": { "status": "ok", "kind": "signal", "accepted": 1 } }

With event_config of array_path: "readings", occurred_at_path: "recorded_at", id_path: "device", and stream: "iot", this lands as two signals — iot.temperature and iot.humidity — timestamped by recorded_at and keyed per device. The field at occurred_at_path is the timestamp, the field at id_path is the entity (the series identity), and every other scalar field becomes a signal keyed stream.field. Nested objects and arrays are skipped. accepted here is the number of rows inserted; a delivery with no scalar fields besides the timestamp and entity accepts 0.

Errors

StatusWhyExample message
404Unknown source_id"Unknown webhook source"
400The source exists but isn't a webhook source"Source is not a webhook"
404Path segment isn't events or signal"Unknown webhook kind (use /events or /signal)"
401Signature missing or doesn't match"Invalid signature"
401No secret configured and unsigned deliveries not allowed"This webhook source has no signature secret configured, so unsigned deliveries are rejected. …"
400Body isn't valid JSON"Invalid JSON body"
500Something failed on our side — safe to retry"Internal error"

Return codes are the only acknowledgement — a 200 means the delivery was verified and processed. Ingested webhook volume counts toward the workspace's streaming allowance; see Billing.

On this page