Aller au contenu

Webhooks API

Ce contenu n’est pas encore disponible dans votre langue.

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.

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.

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": …}
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.

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
Terminal window
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.

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
Terminal window
# Move an endpoint to a new address
curl -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"

Removes the endpoint, its secrets, its delivery history and anything still waiting. 204, or 404 if there’s no such endpoint.

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.

Terminal window
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.

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." }
}
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

The same fields, plus payload: the exact JSON that was (or will be) sent. 404 if there’s no such delivery.

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.

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.

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.

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}.

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.

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 be localhost or end in .localhost, .local or .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.1 is refused.
  • The well-known NAT64 prefix 64:ff9b::/96 is how IPv6-only networks reach IPv4 hosts, so it isn’t refused outright: 64:ff9b::8.8.8.8 is allowed, while 64:ff9b::10.0.0.1 and 64:ff9b::169.254.169.254 are 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.

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