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:
| URL | Delivers to |
|---|---|
POST /v1/ingestion/webhooks/{source_id}/events | The 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}/signal | Signal 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"
}
}'from predictai import PredictAI
client = PredictAI(token="pa_live_…", workspace_id="ws_…")
data = client.ingestion.post_ingestion_connect_create_event(json={
"mode": "push",
"name": "Checkout alerts",
"event_config": {
"stream": "checkout_alerts",
"array_path": "events",
"text_path": "message",
"occurred_at_path": "occurred_at",
"id_path": "id",
},
})
import { PredictAI } from "@predictai/sdk";
const client = new PredictAI({ token: "pa_live_…", workspaceId: "ws_…" });
const data = await client.ingestion.postIngestionConnectCreateEvent({
json: {
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
| Field | Meaning |
|---|---|
mode required | push for a webhook URL. (poll creates a scheduled REST poll instead — see the callout below.) |
name optional | Display name for the source. |
event_config optional | How the receiver reads each delivery — fields below. |
The event_config fields:
| Field | Meaning |
|---|---|
stream optional | Name of the event stream / signal key prefix. Defaults to events. |
array_path optional | Dot/bracket path to the list of items in the payload (e.g. data.items). Default: the whole body is one item. |
text_path optional | Path to the event text within an item. Defaults to text. |
occurred_at_path optional | Path to when it happened — epoch seconds, epoch ms, or ISO-8601. Defaults to occurred_at; missing means "now". |
id_path optional | Path 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-SignatureX-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
| Status | Why | Example message |
|---|---|---|
404 | Unknown source_id | "Unknown webhook source" |
400 | The source exists but isn't a webhook source | "Source is not a webhook" |
404 | Path segment isn't events or signal | "Unknown webhook kind (use /events or /signal)" |
401 | Signature missing or doesn't match | "Invalid signature" |
401 | No secret configured and unsigned deliveries not allowed | "This webhook source has no signature secret configured, so unsigned deliveries are rejected. …" |
400 | Body isn't valid JSON | "Invalid JSON body" |
500 | Something 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.

