Skip to content

Calling the decision API

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.

You need:

  • An API key with the decide scope. 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.
  1. 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_fingerprint and session_id matter 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.

  2. 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
  3. 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
    }
  4. Act on outcome, as below.

  5. Later, report what happened: whether the payment completed, and whether it turned out to be fraud. See Feedback and fraud labels.

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

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.

  1. Set your HTTP timeout above the time budget, with room for the network: for example, the budget plus 100 to 200 ms.
  2. 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.
  3. Retry the same request, with the same external_id and 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.

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.

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 422 or 503), 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_id to the ingest API is refused with external_id was already used for a transaction sent to the decision API.

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.

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.

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