Calling the decision API
In short
Section titled “In short”Your payment system calls POST /api/v1/decisions before it authorises a payment, waits for
the answer, and acts on it: let the payment through, challenge the customer, hold it, or decline
it.
This page is for the developers who connect your payment switch, wallet platform or card authorisation service. For what the answers mean, read Real-time decisions first.
Before you start
Section titled “Before you start”You need:
- An API key with the
decidescope. An administrator creates it under Settings → API keys by ticking decide. See Managing API keys. Give your payment system its own key: if it ever leaks, you can revoke it without stopping your data feeds. - Real-time rules switched on. The decision API answers from your workspace’s real-time
rules. With none running, every payment is answered
allow. See Installing a rule pack. - Exchange rates, if you take foreign currency. Amounts are converted to your base currency exactly as for the ingest API, so a payment in a currency with no rate on record is refused. See FX rates.
- A decision policy. Agree with your risk team what happens when the checks can’t finish in time, and set it under Settings → Workspace. See Workspace settings: decision policy.
How to ask for a decision
Section titled “How to ask for a decision”-
Build the request. The body is one transaction, in exactly the same format as the ingest API, not a batch. Send everything you know at that moment: the more you send, the more the rules can check.
{"external_id": "MOMO-2026-10-09-000201117","occurred_at": "2026-10-09T14:31:07+00:00","customer_external_id": "CUST-000412","account_external_id": "WAL-233241000412","direction": "out","type": "transfer","channel": "momo","status": "pending","amount_minor": 240000,"currency": "GHS","device_fingerprint": "a8f3c2d9-6b1e-4f7a-9c2d-3e5f7a9b1c4d","session_id": "app-sess-7f3a91","ip": "41.66.200.17","counterparty": {"identifier": "233551234567","identifier_type": "msisdn","name": "Kojo Mensah","provider": "mtn_momo"}}device_fingerprintandsession_idmatter more here than anywhere else: they power the new-device, shared-device and account-event rules. The fingerprint is stored only as a keyed hash. -
Send it with your key.
Terminal window curl -s https://speculapro.example.com/api/v1/decisions \-H "Authorization: Bearer $SPECULAPRO_DECIDE_KEY" \-H 'Content-Type: application/json' \-d @payment.json -
Read the answer (
200 OK):{"decision_id": "01a1208b-b57c-7a8f-a1a2-0f95ed78d63c","transaction_id": "324812f5-310d-5090-a3a3-ca3d55145581","external_id": "MOMO-2026-10-09-000201117","outcome": "block","score": 85,"reasons": [{"rule": "gh.sim_swap_payout","name": "Payout soon after a SIM swap","action": "block","severity": "critical","score": 85,"explanation": "SIM swapped 48 hours or less before this payout of GHS 2,400."}],"degraded": false,"latency_ms": 7.8,"decided_at": "2026-10-09T14:31:07.912Z","replayed": false} -
Act on
outcome, as below. -
Later, report what happened: whether the payment completed, and whether it turned out to be fraud. See Feedback and fraud labels.
The answer, field by field
Section titled “The answer, field by field”| Field | Meaning |
|---|---|
decision_id |
This decision’s ID. Store it with the payment: it is what you look up on the Decisions page and in the API |
transaction_id |
SpeculaPro’s ID for the transaction. It is derived from your external_id, so it is the same however many times you retry |
external_id |
Your ID, echoed back |
outcome |
allow, step_up, review or block |
score |
The combined score of the rules that fired, 0 to 100 |
reasons |
The live rules that fired, strongest action first, then highest score: rule (its key), name, action, severity, score and the explanation. Empty when nothing fired |
degraded |
true when the checks didn’t finish and your failure policy answered instead |
note |
Present when the answer isn’t simply the strongest rule’s action: a score threshold raised it, an allow rule waived a review or step-up, or the failure policy answered |
latency_ms |
How long SpeculaPro took, measured on its server. Your own measurement also includes the network |
decided_at |
When the decision was made |
replayed |
true when this is the stored answer to an earlier request with the same external_id |
What your payment system should do
Section titled “What your payment system should do”outcome |
Do this | Then report |
|---|---|---|
allow |
Authorise the payment | status once it completes or fails |
step_up |
Challenge the customer: one-time code, PIN, 3-D Secure or biometrics. Authorise only if they pass | step_up_passed, and the final status |
review |
Hold the payment and put it in front of your fraud team. Tell the customer it is being checked | The final status, and a label once your team has decided |
block |
Decline the payment | status: "failed" or "cancelled", and a label when you learn more |
Treat any outcome you don’t recognise as review, so that a future answer can never let a
payment through by accident.
Timeouts and fallbacks on your side
Section titled “Timeouts and fallbacks on your side”SpeculaPro answers within your workspace’s time budget (150 ms unless your administrator changes it), measured from when your request reaches it. Your system still needs its own timeout, because the network can be slow or the service unreachable.
- Set your HTTP timeout above the time budget, with room for the network: for example, the budget plus 100 to 200 ms.
- Decide your own fallback for when you get no answer at all: a timeout, a connection error
or a
503. Most institutions mirror their workspace’s failure policy: allow low-value payments and hold the rest. - Retry the same request, with the same
external_idand the same body, once the service answers again. You get the original decision if there was one, or a new decision if the first request never arrived. Then report what your fallback did through feedback.
Retries and duplicates
Section titled “Retries and duplicates”The external_id makes the API idempotent: asking twice about the same payment can’t do
anything twice.
| You send | You get |
|---|---|
The same external_id and the same body again |
The original answer, with replayed: true. Nothing is counted again |
The same external_id with a different body |
409 conflict: external_id was already used for a different transaction |
An external_id already sent through the ingest API |
409 conflict: external_id was already used for a transaction sent to the ingest API |
The same external_id while the first request is still being decided |
409 conflict: a request with this external_id is still being decided; retry. Wait a moment and retry: you then get the original answer |
“The same body” means the same payment details. Writing a time as …Z or …+00:00 makes no
difference.
The original answer is kept for the idempotency window: 72 hours unless your operator changes
SP_DECISION_IDEMPOTENCY_TTL. A retry after that is decided again, but the payment is still
recorded only once.
Two requests at once
Section titled “Two requests at once”When a request arrives, SpeculaPro claims its external_id in the same step as it looks for an
earlier answer. A second request with the same external_id that arrives while the first is
still being decided gets the 409 above, never a second decision.
- Once the first is answered, a retry gets that answer with
replayed: true. - If the first request is refused (a
422or503), the claim is dropped at once, so you can correct the request and send it again. - If the server deciding it stops before answering, the claim lapses after 30 seconds, and the next request is decided afresh.
- While it is claimed, sending the same
external_idto the ingest API is refused with external_id was already used for a transaction sent to the decision API.
When SpeculaPro is degraded
Section titled “When SpeculaPro is degraded”If the checks can’t finish inside the time budget, SpeculaPro doesn’t make you wait. It answers
with your workspace’s failure policy and sets degraded: true, with no reasons:
| Policy | outcome |
note, for example |
|---|---|---|
| Fail open | allow |
checks didn’t finish (timeout); fail-open policy applied |
| Fail closed | review |
checks didn’t finish (dependency); fail-closed policy applied |
The word in brackets is the cause:
| Cause | What happened |
|---|---|
timeout |
The checks took longer than the time budget |
dependency |
Something the checks need, such as the feature store (Redis), failed |
overload |
The server was already deciding as many payments as it allows at once, so it answered at once rather than queue yours |
A degraded payment is still recorded and goes through post-event monitoring, and every degraded answer raises a fraud alert, whichever the policy:
- Fail open: the alert says the payment was allowed without real-time checks, so someone looks at it afterwards.
- Fail closed: the alert says the payment is held: someone must review it, then release or decline it in your own systems. SpeculaPro has no button to release a payment.
If the message log is down
Section titled “If the message log is down”After answering, SpeculaPro hands the payment to its message log (Kafka) to be stored and monitored. If Kafka is unavailable, the payment is parked in Redis and delivered when Kafka returns. Your answer is never delayed by this. The payment appears on the Decisions page and in alerts once it is delivered.
Errors
Section titled “Errors”| Status | detail |
What to do |
|---|---|---|
401 |
api key required, invalid api key |
Send a valid key in Authorization: Bearer …. A browser session can’t ask for decisions |
403 |
api key lacks scope decide |
Create a key with the decide scope |
409 |
conflict: external_id was already used for … |
See Retries and duplicates. Don’t retry: fix the ID |
409 |
conflict: a request with this external_id is still being decided; retry |
The same payment is being decided right now. Retry shortly with the same request. See Two requests at once |
422 |
invalid transaction, with the problems listed in errors, such as occurred_at is in the future or no FX rate for USD→GHS on or before 2026-10-09; load rates via /api/v1/ingest/fx-rates |
Fix the request. Don’t retry it unchanged |
503 |
decision service unavailable; apply your fallback and retry |
The payment couldn’t even be recorded, for example because exchange rates couldn’t be looked up. Apply your fallback and retry later with the same external_id |