Webhooks API
Webhooks send events from SpeculaPro to your own systems: blocked and held payments, new alerts, case changes. This page covers two things:
- Managing endpoints through the API, for scripting what Settings → Webhooks does, plus the two things the screen doesn’t offer: changing an endpoint’s address or description, and rotating a secret with an overlap other than 24 hours.
- What SpeculaPro sends: the request, its headers, each event’s payload and the retry schedule. To check signatures on your side, see Receiving webhooks.
Before you start
Section titled “Before you start”Who can call them. Signed-in people with the webhooks.manage permission (by default, the
Administrator). An API key gets 401; anyone without the permission gets
403 missing permission webhooks.manage. Changes are written to the audit log
under your name.
Signing in with curl. Sign in once and keep the session cookie, as described in
Alerts and cases API: before you start, then add
-b ~/.sp-cookies to every request below.
Endpoints at a glance
Section titled “Endpoints at a glance”| Endpoint | Does | Success |
|---|---|---|
GET /api/v1/webhooks/event-types |
List the events an endpoint can subscribe to | 200 |
GET /api/v1/webhooks |
List endpoints, with their health | 200 |
POST /api/v1/webhooks |
Register an endpoint; returns its signing secret once | 201 {"id": …, "secret": …} |
PATCH /api/v1/webhooks/{id} |
Change the address, description or events; switch it off or on | 204 |
DELETE /api/v1/webhooks/{id} |
Delete an endpoint with its delivery history | 204 |
POST /api/v1/webhooks/{id}/rotate-secret |
Issue a new signing secret; returns it once | 200 {"id": …, "secret": …} |
POST /api/v1/webhooks/{id}/test |
Queue a webhook.test event |
202 {"id": …} |
GET /api/v1/webhooks/{id}/deliveries |
An endpoint’s recent deliveries | 200 |
GET /api/v1/webhook-deliveries/{id} |
One delivery, with the exact payload sent | 200 |
POST /api/v1/webhook-deliveries/{id}/resend |
Send a delivered or failed event again | 202 {"id": …} |
The endpoint object
Section titled “The endpoint object”| Field | Meaning |
|---|---|
id |
Its ID |
url |
Where events are sent |
description |
Your note about it; an empty string if none |
events |
The event types it subscribes to, in alphabetical order |
enabled |
false when switched off, by a person or automatically |
disabled_reason |
Why it is off: Disabled by a person, or Disabled automatically: every delivery failed since <time> |
failing_since |
When the current run of failures began. Absent while healthy |
last_success_at |
The last delivery your receiver accepted |
previous_secret_expires_at |
During a rotation, until when the previous secret also signs |
created_at |
When it was registered |
delivered_24h, failed_24h |
Deliveries that succeeded, and that finally failed, in the last 24 hours |
pending |
Events waiting to be sent or retried |
The signing secret is never returned here; only POST /api/v1/webhooks and rotate-secret return it,
once.
Register an endpoint
Section titled “Register an endpoint”POST /api/v1/webhooks
Section titled “POST /api/v1/webhooks”| Field | Required | Rules | Meaning |
|---|---|---|---|
url |
Yes | 8 to 2,000 characters; https://; see Which addresses are refused |
Your receiver |
description |
No | Up to 200 characters | What it is |
events |
Yes | 1 to 20 event types from GET /api/v1/webhooks/event-types |
What it receives. Duplicates are removed |
curl -s -b ~/.sp-cookies -H 'Content-Type: application/json' \ -d '{ "url": "https://hooks.your-bank.com/speculapro", "description": "Fraud desk case system (production)", "events": ["decision.block", "decision.review", "alert.created"] }' \ "$SP/api/v1/webhooks"{ "id": "01a1208b-b05c-725e-86ce-13d9b0159507", "secret": "whsec_…" }A workspace can have up to 20 endpoints.
Change an endpoint
Section titled “Change an endpoint”PATCH /api/v1/webhooks/{id}
Section titled “PATCH /api/v1/webhooks/{id}”Send only what you want to change. The URL and description and Events forms on an endpoint’s sheet in Settings → Webhooks use this endpoint too.
| Field | Rules | Meaning |
|---|---|---|
url |
As for registering | A new address. It is checked the same way |
description |
Up to 200 characters | A new description; "" removes it |
events |
1 to 20 event types | Replaces the whole list |
enabled |
true or false |
false switches it off (disabled_reason becomes Disabled by a person). true switches it back on and clears its failure history; events still waiting resume at once |
# Move an endpoint to a new addresscurl -s -b ~/.sp-cookies -X PATCH -H 'Content-Type: application/json' \ -d '{"url":"https://hooks2.your-bank.com/speculapro"}' \ "$SP/api/v1/webhooks/01a1208b-b05c-725e-86ce-13d9b0159507"Delete an endpoint
Section titled “Delete an endpoint”DELETE /api/v1/webhooks/{id}
Section titled “DELETE /api/v1/webhooks/{id}”Removes the endpoint, its secrets, its delivery history and anything still waiting. 204, or 404
if there’s no such endpoint.
Rotate the signing secret
Section titled “Rotate the signing secret”POST /api/v1/webhooks/{id}/rotate-secret
Section titled “POST /api/v1/webhooks/{id}/rotate-secret”| Field | Required | Rules | Meaning |
|---|---|---|---|
overlap_hours |
No (default 24) |
0 to 168 | How long the previous secret keeps signing too |
Returns the new secret, once. During the overlap, every request carries two signatures in
webhook-signature, one from each secret, so your receiver keeps working with either. With
"overlap_hours": 0 the old secret stops at once: use that when it has leaked.
curl -s -b ~/.sp-cookies -H 'Content-Type: application/json' \ -d '{"overlap_hours": 0}' \ "$SP/api/v1/webhooks/01a1208b-b05c-725e-86ce-13d9b0159507/rotate-secret"Rotating again during an overlap replaces the previous secret: only the two most recent secrets ever sign.
Send a test event
Section titled “Send a test event”POST /api/v1/webhooks/{id}/test
Section titled “POST /api/v1/webhooks/{id}/test”No body. Queues a webhook.test event for this endpoint, whatever it subscribes to, and returns the
delivery’s ID with 202. It is sent within seconds; read the result from the delivery log. A test
for a switched-off endpoint waits until it is switched on. Each test is recorded in the
audit log as webhook.test.
{ "id": "01a1209f-6c1e-7b52-9d0a-3e5f1c2b7a10", "type": "webhook.test", "created_at": "2026-10-09T14:02:11.402Z", "workspace_id": "01a1202b-511e-7090-9a84-bf242804986c", "data": { "message": "Test event from SpeculaPro. Verify the signature, then answer 2xx." }}Read the delivery log
Section titled “Read the delivery log”GET /api/v1/webhooks/{id}/deliveries
Section titled “GET /api/v1/webhooks/{id}/deliveries”| Query parameter | Meaning |
|---|---|
status |
pending, delivered or failed. Leave it out for all |
limit |
1 to 200 (default 50) |
Newest first. Deliveries are kept for 30 days (SP_WEBHOOKS_RETENTION on a self-hosted
installation). An unknown endpoint ID gives an empty list.
| Field | Meaning |
|---|---|
id |
The delivery’s ID. A resend is a new delivery |
endpoint_id |
The endpoint |
event_id |
The event’s ID, sent as the webhook-id header. The same on every retry and resend |
event_type |
For example decision.block |
status |
pending (waiting to be sent or retried), delivered or failed (every attempt failed) |
attempts |
Attempts made so far |
next_attempt_at |
While pending: when the next attempt is due |
last_attempt_at, last_status_code, last_duration_ms |
The last attempt: when, your receiver’s HTTP status (if it answered) and how long it took |
last_error |
Why the last attempt failed, for example HTTP 500: …, no response within 10s, connection failed or destination resolves to a private or reserved address. Your receiver’s error text is cut to 200 characters |
delivered_at |
When your receiver accepted it |
created_at |
When the event was queued |
GET /api/v1/webhook-deliveries/{id}
Section titled “GET /api/v1/webhook-deliveries/{id}”The same fields, plus payload: the exact JSON that was (or will be) sent. 404 if there’s no such
delivery.
Resend an event
Section titled “Resend an event”POST /api/v1/webhook-deliveries/{id}/resend
Section titled “POST /api/v1/webhook-deliveries/{id}/resend”No body. Queues the same event again for the same endpoint, with the same event_id, and returns the
new delivery’s ID with 202. Only delivered or failed deliveries can be resent; a pending one
gives 404 not found: delivery not found or still pending.
What SpeculaPro sends
Section titled “What SpeculaPro sends”Every event is an HTTPS POST to your endpoint’s address.
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
SpeculaPro-Webhooks/<version> |
webhook-id |
The event’s ID. The same on every retry and resend: use it to ignore duplicates |
webhook-timestamp |
When this attempt was signed, in whole seconds since 1 January 1970 (Unix time) |
webhook-signature |
v1, followed by the base64 HMAC-SHA256 signature; two of them, separated by a space, during a secret rotation |
The signing follows the Standard Webhooks specification, so any Standard Webhooks library can check it. How to check it yourself: Receiving webhooks.
Your receiver must answer with a 2xx status within 10 seconds. Anything else is a failure: a 3xx (redirects are never followed), 4xx or 5xx status, a timeout, or a connection that can’t be made.
The envelope
Section titled “The envelope”Every event has the same outer shape:
| Field | Meaning |
|---|---|
id |
The event’s ID, the same as the webhook-id header |
type |
The event type |
created_at |
When the event happened in SpeculaPro |
workspace_id |
Your workspace’s ID |
data |
The event’s details, below |
Payloads carry SpeculaPro’s IDs and your own references (external_ids), never names, phone
numbers or account numbers. Your system looks those up itself.
What each event contains
Section titled “What each event contains”decision.block, decision.review, decision.step_up and decision.degraded
{ "id": "01a1208c-0c9a-73e4-89c9-5a8a6e1cf30a", "type": "decision.block", "created_at": "2026-10-09T12:03:31.610248Z", "workspace_id": "01a1202b-511e-7090-9a84-bf242804986c", "data": { "decision_id": "01a1208c-0c8c-72c3-969d-71d7d08477ee", "transaction_id": "1cedbda5-20a1-5ccb-863b-407dad4852df", "external_id": "ct-7-10653", "customer_external_id": "c-2031", "outcome": "block", "score": 80, "degraded": false, "rules": [ { "rule": "fraud.card_testing", "action": "block", "severity": "high", "score": 80 } ], "amount_minor": 450, "currency": "GHS", "decided_at": "2026-10-09T12:03:31.600743Z" }}| Field | Meaning |
|---|---|
decision_id |
The decision’s ID, as returned by the decision API |
transaction_id |
SpeculaPro’s ID for the transaction |
external_id |
Your transaction reference |
customer_external_id |
Your customer reference |
account_external_id |
Your account reference. Left out when the request had none |
outcome |
block, review or step_up (for decision.degraded, also allow) |
score |
The decision’s score |
degraded |
true if the workspace’s failure policy answered because the checks couldn’t finish |
rules |
The real-time rules that fired: library key, action, severity and score. Empty for a degraded decision |
amount_minor, currency |
The amount in minor units (pesewas for GHS) and its currency, as sent |
decided_at |
When the decision was made |
A decision.review that was also degraded sends decision.degraded too, with the same data.
Allowed decisions send nothing unless degraded.
alert.created
{ "id": "01a1208c-0c3c-7173-b676-fd8a1c59763d", "type": "alert.created", "created_at": "2026-10-09T12:03:31.516088Z", "workspace_id": "01a1202b-511e-7090-9a84-bf242804986c", "data": { "alert_id": "01a1208c-0c3a-7f30-b901-aae8d89784a6", "kind": "fraud", "severity": "high", "score": 80, "rules": ["fraud.card_testing"], "subject_type": "customer", "subject_external_id": "c-2031", "link": "/alerts/01a1208c-0c3a-7f30-b901-aae8d89784a6" }}| Field | Meaning |
|---|---|
alert_id |
The alert’s ID |
kind |
aml (post-event monitoring) or fraud (real-time rules) |
severity |
low, medium, high or critical |
score |
The alert’s score |
rules |
Library keys of the rules that fired |
subject_type |
What the alert is about: customer, account or counterparty |
subject_external_id |
Your reference for that customer or account. Left out for a counterparty |
case_id |
Set when the new alert joined a case that was already open |
link |
The alert’s path in SpeculaPro. Add your SpeculaPro address in front |
case.created, case.assigned, case.escalated, case.closure_requested, case.closed, case.reopened and case.sla_breached
{ "id": "01a120a4-77d0-7e1c-8b0f-6a2d9c4e5f11", "type": "case.closed", "created_at": "2026-10-09T15:40:02.118Z", "workspace_id": "01a1202b-511e-7090-9a84-bf242804986c", "data": { "case_id": "01a12051-2f6b-7c3a-9e44-1b2c3d4e5f60", "number": "SP-2026-000123", "kind": "aml", "status": "closed", "priority": "high", "escalation_level": 1, "disposition": "report_filed", "subject_type": "customer", "subject_external_id": "c-1002", "assignee_id": "01a1202b-6a10-7c2d-8e3f-0a1b2c3d4e5f", "sla_due_at": "2026-10-12T15:40:02Z", "link": "/cases/01a12051-2f6b-7c3a-9e44-1b2c3d4e5f60" }}Each case event carries the case as it is just after the change:
| Field | Meaning |
|---|---|
case_id, number |
The case’s ID and number |
kind |
aml, fraud, sanctions or other |
status, priority, escalation_level |
Its current state |
disposition |
How it was closed. Present once a disposition is set |
subject_type, subject_external_id |
What it is about, with your reference for a customer or account |
assignee_id |
The SpeculaPro user ID of its owner. Left out when unassigned |
sla_due_at |
Its deadline, if it has one |
link |
The case’s path in SpeculaPro |
| Event | Sent when |
|---|---|
case.created |
Someone opens a case from an alert |
case.assigned |
A case is assigned or unassigned. Not sent when a case is only put in or taken out of a team’s queue |
case.escalated |
A case is escalated to the next level, by a person or by its escalation policy after a missed deadline |
case.closure_requested |
A closure is proposed and needs a second approver |
case.closed |
A case is closed directly, or its proposed closure is approved |
case.reopened |
A closed case is reopened |
case.sla_breached |
A case passes its deadline while still open. Sent once per deadline, within about a minute of it; not sent for closed cases or closures waiting for approval. sla_due_at is the deadline that was missed. If the case’s policy escalates it automatically, a case.escalated follows, with the new level and deadline |
A case opened by a policy that starts above level 1 is unassigned, so it sends case.created
without a case.assigned. The payload doesn’t carry the team queue or the policy; read them from
GET /api/v1/cases/{id}.
Retries
Section titled “Retries”A failed attempt is retried after these waits:
| After attempt | Wait | Next attempt, from the first |
|---|---|---|
| 1 | 5 seconds | about 5 seconds |
| 2 | 1 minute | about 1 minute |
| 3 | 5 minutes | about 6 minutes |
| 4 | 30 minutes | about 36 minutes |
| 5 | 2 hours | about 2 hours 36 minutes |
| 6 | 5 hours | about 7 hours 36 minutes |
| 7 | 10 hours | about 17 hours 36 minutes |
| 8 | 10 hours | about 27 hours 36 minutes |
That is nine attempts in all. If the ninth fails, the delivery is failed and isn’t tried again
unless someone resends it. Each attempt can also take up to the 10-second
timeout, and due deliveries are picked up every second.
An endpoint that has kept failing for 3 days without a single success is switched off
automatically at its next failed attempt, and administrators are notified. An endpoint with no
attempts left and no new events stays enabled, with failing_since set. See When an endpoint is switched off.
On a self-hosted installation the timeout, the 3 days and the 30-day history are
SP_WEBHOOKS_TIMEOUT, SP_WEBHOOKS_DISABLE_AFTER and SP_WEBHOOKS_RETENTION.
Rarely, the same attempt can be made twice: if a SpeculaPro worker stops in the middle of a delivery, another one sends it again once the first worker’s reservation of that batch runs out (a few minutes with the default settings; see Running more than one worker). Events can also arrive out of order. Your receiver should ignore duplicates and not depend on order.
Which addresses are refused
Section titled “Which addresses are refused”Webhook addresses are chosen by your workspace’s administrators, so SpeculaPro makes sure they can’t be used to reach its own internal network: databases, admin ports or a cloud provider’s metadata service. Addresses are checked twice:
- When you register or change an endpoint. The address must use
https://, carry no user name or password, and have no#fragment. The host can’t belocalhostor end in.localhost,.localor.internal, can’t be a cloud metadata name (metadata,metadata.google.internal,metadata.goog,instance-data,instance-data.ec2.internal,metadata.azure.com), and can’t be a private or reserved IP address. - At every connection, after the host name has been looked up. If it resolves to a private or
reserved address, the attempt fails with
destination resolves to a private or reserved address. This stops a host name that later starts pointing inside the network.
Redirects are never followed, and no outbound proxy is used, so the address checked is always the address connected to.
The refused ranges are:
| Range | What it is |
|---|---|
0.0.0.0/8 |
“This network”. 0.0.0.0 reaches the machine itself on most systems |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
Private networks |
100.64.0.0/10 |
Carrier-grade NAT |
127.0.0.0/8, ::1 |
This machine (loopback) |
169.254.0.0/16, fe80::/10 |
Link-local, including cloud metadata services at 169.254.169.254 |
192.0.0.0/24 |
IETF protocol assignments |
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32 |
Reserved for documentation |
192.88.99.0/24 |
6to4 relays |
198.18.0.0/15 |
Benchmark testing |
224.0.0.0/4, ff00::/8 |
Multicast |
240.0.0.0/4 |
Reserved, including the broadcast address 255.255.255.255 |
::/96 |
The unspecified address ::, and the old IPv4-compatible form ::a.b.c.d |
::ffff:0:0:0/96 |
IPv4-translated addresses |
64:ff9b:1::/48 |
Local-use NAT64, which can carry any IPv4 address |
100::/64 |
Discard-only |
2001::/32 |
Teredo tunnels, which lead to an IPv4 address |
2002::/16 |
6to4 tunnels, which lead to an IPv4 address |
fc00::/7 |
IPv6 private networks (unique local) |
fec0::/10 |
Site-local (retired, but some systems still route it) |
Two IPv6 forms carry an IPv4 address and are judged by that address instead:
- IPv4-mapped addresses (
::ffff:10.0.0.1) are checked as the IPv4 address they contain, so::ffff:10.0.0.1is refused. - The well-known NAT64 prefix
64:ff9b::/96is how IPv6-only networks reach IPv4 hosts, so it isn’t refused outright:64:ff9b::8.8.8.8is allowed, while64:ff9b::10.0.0.1and64:ff9b::169.254.169.254are refused.
On a self-hosted installation whose receivers are on your own network, an operator can allow
private addresses with SP_WEBHOOKS_ALLOW_PRIVATE=true (on the api and worker processes), and,
for development only, plain http:// with SP_WEBHOOKS_ALLOW_HTTP=true. See
Configuration: webhooks.
“Private” means private networks proper, and nothing more:
| With allow-private on | Ranges |
|---|---|
| Opened | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, IPv6 unique-local fc00::/7, and loopback 127.0.0.0/8 and ::1 |
| Still refused, always | 0.0.0.0/8, link-local 169.254.0.0/16 and fe80::/10, the cloud metadata addresses 169.254.169.254, fd00:ec2::254 (AWS over IPv6) and 100.100.100.200 (Alibaba Cloud), and the metadata names above, including in IPv4-mapped and NAT64 form |
| Still refused | Carrier-grade NAT 100.64.0.0/10, documentation, benchmarking, multicast and the other reserved ranges in the table above |
The same check, with the same ranges, guards every connection SpeculaPro makes to an address someone configured: webhooks, data-source SFTP servers and buckets, single sign-on identity providers and the GOWA WhatsApp server. Each has its own allow-private setting.
Errors
Section titled “Errors”| Status | Meaning | Examples |
|---|---|---|
401 |
Not signed in, or called with an API key | |
403 |
Your role lacks webhooks.manage |
missing permission webhooks.manage |
404 |
No such endpoint or delivery in your workspace, or resending a pending delivery | not found; not found: delivery not found or still pending |
422 |
A field fails its rules | invalid: use https; webhooks carry signed business events; invalid: use an https URL; invalid: not a valid URL; invalid: destination not allowed: localhost is an internal host; invalid: destination not allowed: 10.0.0.5 is a private or reserved address; invalid: credentials in the URL aren't allowed; verify the signature instead; invalid: the URL can't have a #fragment; invalid: subscribe to at least one event; invalid: unknown event "decision.allow"; invalid: at most 20 endpoints per workspace. Out-of-range values, such as overlap_hours above 168 or more than 20 events, are listed field by field |