A chargeback is not a single API request. It is a case that can remain open for days while evidence arrives, deadlines approach, and a reviewer decides what should happen next.
That makes chargeback handling an interesting systems problem. The application needs to remember the case, wake up at the right time, evaluate evidence consistently, communicate with the customer, and preserve an audit trail even when the runtime restarts.
The chargeback-adjudication TypeScript example models each dispute as a durable actor running on Telnyx Edge Compute.
The sample combines:
- One Stateful Actor per dispute
- Telnyx Decision Models for structured evidence evaluation
- Scheduled tasks for decision and response deadlines
- SQL for evidence, review queues, and an append-only audit ledger
- SMS for customer updates and reviewer escalation
The repository uses synthetic evidence and defaults to DEMO_MODE=true, so SMS messages are logged instead of sent while you explore the workflow.
The architecture
The central design choice is simple: the actor is the dispute case.
Chargeback webhook
|
v
DisputeCase actor, keyed by disputeId
|
+-- SQL evidence and audit rows
+-- Scheduled decision task
+-- Telnyx Decision Models request
+-- Policy and human-review routing
+-- Customer or reviewer SMS
Because the actor is keyed by disputeId, later evidence is routed back to the same durable case instead of being reconstructed from a stateless webhook request.
Create a case from a webhook
The Edge handler accepts a chargeback event at POST /webhook/chargeback:
if (path === "/webhook/chargeback" && req.method === "POST") {
const payload = parseJson<ChargebackPayload>(await req.text());
if (!payload || !payload.disputeId || !payload.customer || !payload.orderId) {
return new Response(
JSON.stringify({
error: "disputeId, customer, and orderId required",
}),
{ status: 400 },
);
}
const stub = e.DISPUTES.idFromName(payload.disputeId);
const result = await stub.onChargeback(payload);
return new Response(JSON.stringify(result), { status: 200 });
}
idFromName() gives every dispute a stable actor identity. Inside onChargeback(), the actor stores the case state, seeds the sample evidence tables, calculates the response window, and schedules a decision task with a stable ID:
this.schedule(0, "decide", {}, {
id: `decide:${disputeId}`,
});
Stable task IDs are useful when webhook delivery or application logic retries. Repeated work can converge on the same scheduled operation instead of creating a new timer every time.
Evaluate several questions in one Decision Models request
The actor assembles the order, delivery record, contact history, and any newly submitted evidence into one shared state object.
It sends that context to:
POST /v2/ai/typesafe/v1/systemone
One request asks three named questions:
const body = {
state: JSON.stringify(evidence),
questions: {
decision: {
type: "choice",
instructions: "Rule on the chargeback.",
criteria: {
approve_rebate: "Delivery evidence supports the customer's order.",
request_evidence: "Evidence is inconclusive; more proof is needed.",
deny: "Evidence supports the merchant; deny the dispute.",
},
},
loseProb: {
type: "score",
instructions: "Rate the merchant's risk of losing.",
criteria: [
"Clearly likely to win",
"Uncertain",
"Clearly likely to lose",
],
},
fraud: {
type: "noul",
instructions: "Does this look like a fraud attempt?",
},
},
};
These question types serve different purposes:
-
choiceselects one supplied category. -
noulproduces a signal between zero and one. -
scorereturns an expected position on the ordered rubric.
A Decision Models score should not automatically be treated as a calibrated probability. With three rubric entries, the result falls along the rubric’s zero-to-two range and can be fractional.
Production policy should be tested against representative, labeled cases rather than treating the result as certainty.
The endpoint defaults to telnyx/decision-flash when model is omitted. You can also set the model explicitly based on the context requirements of your workflow.
Keep model output separate from business policy
The model produces structured evidence signals. Application code still owns the business action.
The sample’s policy layer can:
- Approve a rebate and notify the customer
- Request additional evidence
- Deny the claim
- Route a suspected-fraud case to a human review queue
if (noul > FRAUD_THRESHOLD) {
await db
.prepare("INSERT INTO reviewQueue VALUES (?, ?, ?)")
.bind(disputeId, new Date().toISOString(), "fraud_hold")
.all();
await sendSms(
reviewerOnCall,
`Review required for ${disputeId}`,
);
return;
}
The threshold in this sample is illustrative. Before using a similar rule in production, evaluate it against your own data, add authorization and reviewer controls, and clearly define which outcomes may be automated.
Decision Models help determine what deserves attention. They do not remove the human reviewer from high-risk decisions.
Re-evaluate when new evidence arrives
A dispute can change after its initial review.
The second webhook route, POST /webhook/inbound-message, sends an SMS or MMS response back to the existing actor:
async onNewEvidence(
text: string,
mediaUrl?: string,
): Promise<void> {
const evidence = await this.assembleEvidence(mediaUrl);
const result = await this.judgeWithDecisionModel({
...evidence,
newEvidence: text,
});
await this.appendAudit("re-evaluated", result);
await this.applyPolicy(result);
}
The case keeps its identity and previous state. New evidence becomes another event in the same lifecycle, and the resulting evaluation is appended to the ledger.
Preserve an audit trail in SQL
Every material event is written as a new row rather than overwriting the previous decision:
await db
.prepare("INSERT INTO audit VALUES (?, ?, ?, ?)")
.bind(
disputeId,
new Date().toISOString(),
event,
JSON.stringify(payload),
)
.all();
This append-only pattern makes it possible to reconstruct:
- What evidence the application had
- Which model output it received
- Which policy branch was selected
- When the state changed
- Whether new evidence caused a re-evaluation
For a real financial or healthcare workflow, this example is only a starting point.
Add authentication, webhook verification, encryption, retention controls, idempotency around external actions, reviewer permissions, monitoring, and a domain-specific compliance review before processing sensitive data.
Run the sample
Clone the examples repository and install the dependencies:
git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/chargeback-adjudication
npm install
cp .env.example .env
The sample configuration includes:
TELNYX_API_KEY=your_telnyx_api_key_here
RESPONSE_DEADLINE_DAYS=7
REVIEWER_ONCALL_E164=+1555XXXXXXXX
DEMO_MODE=true
Authenticate the Edge CLI, run the smoke test, and deploy:
telnyx-edge auth api-key set <your_telnyx_api_key>
npx tsx smoke_test.ts
telnyx-edge ship
DEMO_MODE=true suppresses real SMS delivery and uses synthetic evidence. The deployed adjudication path still requires a Telnyx API key to call Decision Models.
Where else this pattern fits
The reusable idea is larger than chargebacks: assign one durable actor to every long-running case, keep its evidence and history nearby, request structured model outputs, and let explicit application policy determine what happens next.
The same architecture could support:
- Insurance claim review
- Returns and refund exceptions
- Account-verification cases
- Compliance investigations
- Support escalations with deadlines
Top comments (0)