DEV Community

Sonam Gupta
Sonam Gupta

Posted on

Build a Durable Chargeback Review Workflow on Telnyx Edge

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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}`,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?",
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

These question types serve different purposes:

  • choice selects one supplied category.
  • noul produces a signal between zero and one.
  • score returns 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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The sample configuration includes:

TELNYX_API_KEY=your_telnyx_api_key_here
RESPONSE_DEADLINE_DAYS=7
REVIEWER_ONCALL_E164=+1555XXXXXXXX
DEMO_MODE=true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Resources

Top comments (0)