Live Decisioning

The synchronous approve/decline webhook Penny calls during authorization.

Live Decisioning is a synchronous, blocking request Penny sends to one of your webhook endpoints in the middle of authorizing a transaction — not the asynchronous, fire-and-forget events described in Webhooks Overview. Penny waits for your response (or a timeout) before the transaction can proceed, so this is fundamentally different from ordinary event delivery.

evaluatedcreatesrequestresponse / timeout / failoutcomeTransaction AuthorizationTRIGGERlive_decisioning RuleSPEND CONTROLDecision ExchangePENDING → RESOLVEDYour Notification EndpointWEBHOOKAuthorization OutcomeAPPROVED / DECLINED

Live Decisioning is configured through a Spend Control’s live_decisioning rule, not through a webhook’s trigger_events subscriptions. See Spend Controls for where this fits among the other rule types and the hierarchy it’s evaluated in.

How it’s set up

A live_decisioning rule has two required configuration fields:

KeyTypeMeaning
notification_endpoint_idstringThe active Notification Endpoint Penny calls. It is registered and verified like an ordinary webhook endpoint, but does not require trigger_events; decision requests are addressed directly to the endpoint.
fallback_resultbooleanRequired. Decision used on timeout, connection failure, or invalid response: true approves and false declines.
A live_decisioning rule's configuration
{
"notification_endpoint_id": "notification_endpoint_01937b28-f048-77de-8ef4-f0487deef42d",
"fallback_result": false
}

The rule configuration has no timeout field. By default, Penny waits 1 second for your response. The timeout in effect for each request is sent as request.timeout_seconds.

The request Penny sends

Penny sends a JSON POST to the configured endpoint. Optional values are shown as null where unavailable; actual values vary with the authorization.

POST to your webhook endpoint
{
"exchange_id": "notification_decision_0193726c-a65e-76c3-8bfb-a65e6c3bfb2f",
"type": "transaction.authorization",
"schema_version": "1.0",
"request_time": "2026-08-25T14:03:11.123456+00:00",
"request": {
"identifier": "transaction.authorization",
"subject": {
"object_type": "transaction",
"object_id": "transaction_01937a7d-ad6b-77ca-8046-ad6b7ca046ce"
},
"timeout_seconds": 1.0,
"fallback": "declined",
"authorization": {
"type": "debit",
"transaction_id": "transaction_01937a7d-ad6b-77ca-8046-ad6b7ca046ce",
"business_id": "business_01937b95-484e-75af-8c68-484e5afc68d3",
"business_entity_id": "business_entity_01a083e9-ea37-755a-a787-75b0f26fd863",
"program_id": "program_019377de-7f88-7f02-894f-7f88f0294fcb",
"account_id": "account_019372e3-b8dc-7de2-80b6-b8dcde20b6c9",
"card_id": "card_019375de-a2a0-7f31-884a-a2a0f3184abd",
"cardholder_id": "cardholder_01937e19-4ebb-75bd-8250-4ebb5bd25085",
"base_amount": { "amount": "108.50", "currency": "USD" },
"purchase_amount": null,
"estimated_fee": null,
"merchant": {
"merchant_id": "WFM-0481726",
"code": "5411",
"category": "Grocery Stores, Supermarkets",
"name": "Whole Foods Market",
"city": "Austin",
"country": "US",
"subdivision": "US-TX",
"zip_code": "78701"
},
"processing_details": {
"pan_entry_mode": "contactless",
"type": "point_of_service",
"bin_id": "bin_019374af-0cc8-703d-852e-0cc803d52ee3",
"bin_range_id": "bin_range_019374af-0cc8-703d-852e-0cc803d52ee3",
"card_product_id": "card_product_019374af-0cc8-703d-852e-0cc803d52ee3",
"card_class": "consumer"
},
"scheme_details": {
"network": "visa",
"funding_type": "prepaid",
"product_type": "consumer"
}
}
}
}

exchange_id is the decision exchange identifier, and type and request.identifier are transaction.authorization. request.subject identifies the transaction. request.fallback is approved or declined, derived from the rule’s boolean fallback_result; request.timeout_seconds is the effective timeout, sent as a JSON number.

authorization.type is the authorization direction (debit, credit, or adjustment), not a payment channel. Optional IDs, amounts, and detail fields may be null.

Authorization fieldWhat Penny sends
transaction_id, account_id, card_idIDs for the transaction being authorized, its funding account, and the card.
business_id, business_entity_id, program_id, cardholder_idBusiness, entity, program, and cardholder IDs when present.
base_amountRequired amount in the card’s billing currency.
purchase_amountMerchant-currency amount when available; otherwise null.
estimated_feeEstimated fee amount when known; otherwise null.
merchantmerchant_id (the card network’s own merchant identifier, reported at authorization time), code (MCC), category, name, city, country, subdivision, and zip_code. Location details can be null.
processing_detailspan_entry_mode, processing type, bin_id, bin_range_id, card_product_id, and card_class.
scheme_detailsNetwork, funding, and product types. Scheme card, program, trace, and transaction identifiers are internal to Penny and not included here.

Every amount uses { "amount": "<decimal string>", "currency": "<ISO 4217 code>" }. For a single-currency transaction, purchase_amount may be null; use base_amount for the amount Penny authorizes.

The response Penny expects

Respond within the timeout with 2xx and this body:

Your response
{
"outcome": "approved",
"reason": "Cardholder passed step-up verification"
}
  • outcome — "approved" or "declined". Required.
  • reason — optional free-text explanation, useful for your own audit trail; Penny doesn’t act on its content beyond storing it.

Any of the following is treated as a failure, and the rule’s fallback_result is applied instead:

  • No response within timeout_seconds.
  • A connection error or non-2xx status code.
  • A 2xx response whose body is missing, isn’t a JSON object, or has an outcome value other than approved/declined.

Verifying the request

Live Decisioning requests go through the exact same signing mechanism as ordinary event deliveries — if the endpoint has a signing secret configured, the request carries Penny-Webhook-Timestamp and Penny-Webhook-Signature headers computed the same way, and if it has an authentication_token, the request carries the same Authorization: Bearer header. See Verifying webhook signatures and Webhook Authorization for both checks.

Evaluation order

Only the smallest control surface with a live_decisioning rule attached is called — a matching rule at a larger control surface is skipped entirely once a smaller one has run. See Spend Controls → Multiple applicable controls for the full hierarchy and a worked example.

The decision exchange record

Every Live Decisioning request Penny sends is recorded as a decision exchange: one request/response round trip with your endpoint. Penny keeps the same record whether the exchange resolves normally, times out, or fails, and you can read it through the API:

GET /notifications/webhooks/exchanges?notification_endpoint_id={notification_endpoint_id}
GET /notifications/webhooks/exchanges/{notification_decision_exchange_id}
  • GET /notifications/webhooks/exchanges lists exchanges for one endpoint. notification_endpoint_id is required, and results are paginated like any other list endpoint.
  • GET /notifications/webhooks/exchanges/{notification_decision_exchange_id} retrieves a single exchange. Pass ?version= to look up a specific past version; it defaults to 0, which always resolves to the latest.

The exchange response looks like this (request.authorization abridged):

GET /notifications/webhooks/exchanges/{notification_decision_exchange_id}
{
"notification_decision_exchange_id": "notification_decision_0193726c-a65e-76c3-8bfb-a65e6c3bfb2f",
"version": 2,
"version_time": "2026-08-25T14:03:11.654321+00:00",
"business_id": "business_01937b95-484e-75af-8c68-484e5afc68d3",
"notification_endpoint_id": "notification_endpoint_01937b28-f048-77de-8ef4-f0487deef42d",
"request_time": "2026-08-25T14:03:11.123456+00:00",
"response_time": "2026-08-25T14:03:11.654321+00:00",
"channel": "webhook",
"status": "decided",
"outcome": "approved",
"fallback_applied": false,
"request": {
"identifier": "transaction.authorization",
"timeout_seconds": 1.0,
"fallback": "approved",
"authorization": {
"transaction_id": "transaction_01937a7d-ad6b-77ca-8046-ad6b7ca046ce"
// … the same authorization object shown above
}
},
"response": {
"outcome": "approved",
"reason": "Cardholder passed step-up verification"
},
"metadata": null
}
FieldTypeMeaning
notification_decision_exchange_idstringUnique identifier for this exchange — the same value delivered to you as exchange_id.
version / version_timeinteger / timestampIncreases each time the exchange’s state changes (for example, pending → decided); use it to confirm you’re looking at the latest state.
business_idstringThe business the exchange belongs to.
notification_endpoint_idstringThe Notification Endpoint the decision was requested from; matches the rule’s notification_endpoint_id.
request_timetimestampWhen Penny sent the request.
response_timetimestamp, optionalWhen a response, timeout, or failure was recorded.
channelstringThe delivery channel used — webhook for these exchanges.
statusstringpending, decided, timed_out, or failed — see below.
outcomestring, optionalapproved or declined. Set from your response once decided, or from the rule’s fallback_result on timed_out or failed. Unset while pending.
fallback_appliedbooleanWhether outcome came from the rule’s fallback rather than your endpoint’s own response.
requestobjectidentifier, timeout_seconds, fallback, and authorization. Unlike the webhook body, the exchange response omits subject; the transaction ID remains in authorization.transaction_id.
responseobject, optionalThe {outcome, reason} your endpoint returned, once decided.
metadataobject, optionalReserved for additional context.

status reflects exactly how the exchange resolved:

StatusMeaning
pendingThe request has been sent; no response yet.
decidedYour endpoint returned a valid {outcome, reason} within the timeout.
timed_outNo response arrived within timeout_seconds.
failedYour endpoint errored, or returned something Penny couldn’t parse as a decision.

Both timed_out and failed apply the rule’s fallback_result to outcome in exactly the same way — the only difference between them is whether anything came back from your endpoint at all.

The supported Live Decisioning type is transaction.authorization; the request shape above applies to that type.