An AI collections agent for Australian tradies and SMEs, built on Pinch Payments.
SettleMate lets an AI agent — voice or text, autonomous or human-in-the-loop — chase overdue invoices, set up payment plans, and take payments on a business's behalf, without ever holding a raw credential or an unbounded blank cheque. Every action a model can take is expressed as a small, typed tool; every tool call is checked against a per-agent spending grant (per-transaction limit, daily limit, allowed-tool list) before it touches money; every attempt — allowed or refused — is written to an append-only audit log. The agent can propose and act; it can never exceed what the business owner explicitly authorised.
It's built end-to-end on Pinch Payments (getpinch.com.au), an Australian direct-debit
and card payment processor: payer/mandate management, scheduled payments, native
recurring payment plans, and a signed webhook feed for settlement events — see
"How Pinch is used" below.
Chasing overdue invoices is one of the most common, most annoying jobs in a small business — and one of the most natural to hand to an AI agent (a voice caller, a chat assistant, an autonomous biller). But "let an LLM touch our merchant account" is a correct and immediate objection: LLMs hallucinate, misread numbers, and can be talked into things by a plausible-sounding customer on the phone. SettleMate's answer isn't "trust the model" — it's a permissions engine the model cannot reason its way around, sitting between every tool call and Pinch's real payment rails.
┌─────────────────┐ ┌──────────────────┐ ┌───────────────────────┐
│ MCP client │ │ ElevenLabs │ │ Dashboard / any │
│ (Claude, etc.) │ │ voice agent │ │ REST client │
└────────┬─────────┘ └────────┬─────────┘ └───────────┬───────────┘
│ POST /mcp │ POST /voice/* │ /admin/*, /api/*
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (this repo) │
│ │
│ 9 agent-facing tools ──────────► permissions.enforce() │
│ (src/tools.ts) grant exists → active → │
│ tool allowed → per-tx limit → │
│ daily limit → execute → audit │
│ │ │
│ ▼ │
│ src/pinch.ts │
│ (typed Pinch API client) │
└───────────────────────────────────┬──────────────────────────────────┘
│ OAuth client-credentials
▼
┌─────────────────────────┐
│ Pinch Payments API │
│ (AU direct debit/card) │
└────────────┬──────────────┘
│ signed webhook events
▼
POST /webhooks/pinch (this repo)
→ settlement lifecycle → audit log
Cloudflare Workers, TypeScript strict throughout, zero servers to manage, KV for all
state (no separate database). Five KV namespaces (GRANTS, SPEND, AUDIT, TOKENS,
IDEMPOTENCY) hold everything: agent grants, daily spend counters, the audit trail,
a cached Pinch OAuth token, and idempotency guards.
The whole trust model in one function, enforce():
grant exists? → grant active? → tool in allowedTools? → amount ≤ per-tx limit?
→ amount + today's spend ≤ daily limit? → execute() → record spend → audit
Every money-touching tool goes through this — no exceptions, no bypass. A refusal at
any step returns a structured { declined: true, reason, limitCents, attemptedCents }
object the calling agent can relay in plain language ("that's over my $2,000 per-payment
authority") instead of a stack trace. create_payment_plan re-checks the same chain
once against a single preflight snapshot rather than per-instalment (it creates one
native Pinch recurring-payment object, not N separate payments — see below) but the
guarantee is identical: nothing is created that the grant wouldn't allow.
- MCP server (
POST /mcp) — the 9 tools over JSON-RPC 2.0, for any MCP-speaking client (Claude Desktop, Claude Code, a custom agent harness). - Voice webhooks (
/voice/*) — the bridge for an ElevenLabs Conversational AI phone agent: a generic tool-invocation endpoint that runs the exact sameenforce()path as MCP and always returns a read-aloudspeechstring, plus call-start context injection so the agent never has to guess a customer's name, balance, or its own spending authority. - REST admin / dashboard facade (
/admin/*,/rest/*,/api/*) — grant management, the audit log, recovered-payments totals, ad-hoc debtor/invoice creation, and (via ElevenLabs) triggering an outbound collections call. - Pinch webhooks (
POST /webhooks/pinch) — receives signed settlement events from Pinch and turns them into audit-log entries (settled/failed/processing), so the dashboard shows real payment lifecycle, not just agent actions.
Every surface bottoms out in the same nine tools — an agent never gets a wider blast radius by switching transport.
| Tool | What it does |
|---|---|
create_payer |
Create a customer in Pinch |
add_bank_account |
Attach a bank-account direct-debit mandate to a payer |
create_invoice |
Schedule a single future debit |
charge_now |
Charge a payer's account immediately (realtime) |
create_payment_plan |
Split a total into 2–6 instalments — ONE native Pinch Plan + Subscription |
cancel_payment_plan |
Cancel every remaining instalment of a plan in one call |
check_payment_status |
Look up a payment's current status |
list_overdue |
List overdue/dishonoured payments, scoped to a payer or global |
get_agent_limits |
Introspect the calling agent's own remaining authority |
Guardrails baked into create_payment_plan specifically (the highest-risk tool, since
it commits to a recurring schedule): a startDate in the past is refused outright, and
the requested total is sanity-checked against the payer's actual overdue balance —
if it's off by more than 20%, nothing is created and the agent gets back a speakable
clarification ("that plan totals $570 but the balance is $880 — should I set it for
the full amount?") instead of silently committing to the wrong figure.
Pinch (docs.getpinch.com.au) is an Australian payment processor — direct debit
(bank account) and card, OAuth client-credentials auth, sandbox base
https://api.getpinch.com.au/test. This project is built directly on top of it, not
alongside it:
- Payers (
POST /payers) — every customer SettleMate can act on is a Pinch payer record (pyr_...). - Bank-account mandates (
POST /payers/{id}/sources) — the direct-debit authorisation (src_...) a payer must have before any charge or scheduled payment can run against them. - Scheduled payments (
POST /payments) — a single future-dated debit, used for one-off invoices (create_invoice). - Realtime payments (
POST /payments/realtime) — an immediate charge (charge_now), with the sandbox's dishonour outcome surfaced back to the agent. - Native recurring payment plans (
POST /plans+POST /subscriptions) —create_payment_plandoesn't loop N individualPOST /paymentscalls; it creates ONE Pinch Plan (afixedPaymentsschedule template — verified live that this, notrecurringPayment, is what's needed to preserve an exact per-instalment amount with a remainder on the final instalment) and ONE Subscription binding it to the payer. Pinch generates every instalment payment itself. Cancelling the whole remaining schedule is a singleDELETE /subscriptions/{id}call (cancel_payment_plan) instead of N individual cancellations. - Webhooks (
POST /webhooks, registered once via the API — Pinch has no dashboard UI for this) — SettleMate'sPOST /webhooks/pinchverifies Pinch'spinch-signature: t=<ts>,v2=<hmac-sha256>header, maps each payment-referencing event to a settlement outcome (transferred→ settled,dishonoured→ failed, anything else → processing), and writes it to both a KV snapshot and the audit log.GET /admin/recoveredprefers this webhook-fed snapshot when one exists (cheaper, reflects real settlement events) and falls back to a live Pinch scan otherwise. - Events / transfers (
GET /events,GET /transfers/items/{id}) — the raw primitives behind the webhook feed and reconciliation, wrapped bysrc/pinch.ts.
Every Pinch fact this codebase relies on (auth quirks, response shapes, status vocabulary, date-validation limits, the Plans/Subscriptions behaviour above) was verified live against the sandbox — not taken on faith from the docs alone.
103 tests across 6 suites, node:test + tsx, no test framework dependency. Pinch
and ElevenLabs are stubbed at the fetch level with response shapes captured from
real sandbox calls; KV is an in-memory mock. test/webhooks.test.ts exercises the
real HMAC-SHA256 signing/verification path (constructs actual signatures with
crypto.subtle, not a mock). test/index.test.ts proves the outer Worker try/catch
actually catches async exceptions, not just synchronous ones — every route handler
call is awaited specifically so a downstream throw can't leak past it as a raw
exception.
npm test # everything
npm run test:perms # permissions engine
npm run test:tools # the 9 tools + guardrails
npm run test:admin # REST admin / dashboard facade
npm run test:voice # ElevenLabs voice webhooks
npm run test:webhooks # Pinch webhook signature verification + event handling
npm run test:index # Worker entry-point error handling
npm run typecheck # tsc --strict, must pass clean# 1. Install
npm install
# 2. Local secrets
cp .dev.vars.example .dev.vars
# then fill in real SANDBOX values — see .dev.vars.example for where each one
# comes from (Pinch dashboard, ElevenLabs dashboard, or generate your own).
# 3. Start the worker
npm run dev
# 4. Seed demo data (creates a grant + 3 payers + overdue invoices)
curl -X POST http://localhost:8787/admin/seed \
-H "Authorization: Bearer <your ADMIN_KEY from .dev.vars>"
# 5. Run the test suite
npm test
# 6. Run the smoke test against your local worker
ADMIN_KEY=<your ADMIN_KEY> npm run smoke
# 7. Run the agent-to-agent demo (two Claude agents transacting through MCP)
ANTHROPIC_API_KEY=<your key> ADMIN_KEY=<your ADMIN_KEY> npm run a2a
ANTHROPIC_API_KEY=<your key> ADMIN_KEY=<your ADMIN_KEY> npm run a2a -- --scenario=blockedANTHROPIC_API_KEY is read directly from the shell (not .dev.vars) — it's only used
by the local agents/a2a.ts demo harness, never by the deployed Worker.
Exact, copy-pasteable sequence for demo mornings: deploy → reset → verify. Run top
to bottom. Replace <PLACEHOLDERS>.
wrangler deploy
# Note the production URL it prints, e.g. https://<your-worker-name>.<subdomain>.workers.dev
export PROD_URL=https://<your-worker-name>.<subdomain>.workers.devwrangler secret put PINCH_SECRET_KEY # paste sk_test_...
wrangler secret put PINCH_PUBLISHABLE_KEY # paste the Application ID (app_test_...)
wrangler secret put PINCH_MERCHANT_ID # paste merchant id (reference only)
wrangler secret put VOICE_SECRET # paste any strong shared secret
wrangler secret put ELEVENLABS_API_KEY # xi-api-key
wrangler secret put ELEVENLABS_AGENT_ID # agent_...
wrangler secret put ELEVENLABS_PHONE_NUMBER_ID # phnum_...
wrangler secret put PINCH_WEBHOOK_SECRET # whsec_... from POST /webhooks
# ADMIN_KEY — generate a fresh one, NEVER a dev default:
openssl rand -hex 24 # copy the output
wrangler secret put ADMIN_KEY # paste the generated value
export ADMIN_KEY=<the-value-you-just-generated>Stale grants from a previous deploy silently break every tool (no_grant). The reset
command cancels all scheduled payments for the demo payers (including any Soapbox
invoices from the A2A demo), then re-seeds fresh overdue invoices. Payer IDs are
stable across resets — the registry is preserved in KV so payerIds never change
between runs.
curl -X POST "$PROD_URL/admin/reset" -H "Authorization: Bearer $ADMIN_KEY"First deploy ever (no payers registered yet)? Use
/admin/seedinstead — it creates the payers and registers them. Every subsequent run uses/admin/reset.
# First deploy only:
curl -X POST "$PROD_URL/admin/seed" -H "Authorization: Bearer $ADMIN_KEY"# a) health (open route)
curl "$PROD_URL/health"
# b) get_agent_limits via MCP — confirms the seeded grant is live
curl -X POST "$PROD_URL/mcp?agentId=demo-agent" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_agent_limits","arguments":{}}}'
# c) recovered total
curl "$PROD_URL/admin/recovered" -H "Authorization: Bearer $ADMIN_KEY"
# d) payments-by-status breakdown
curl "$PROD_URL/admin/payments/status" -H "Authorization: Bearer $ADMIN_KEY"
# e) voice surface — list seeded debtors (confirms VOICE_SECRET is live)
curl "$PROD_URL/voice/payers" -H "X-Voice-Secret: $VOICE_SECRET"Optional — voice demo without hardcoding a payerId:
/voice/toolalready defaults"TARGET"/omittedpayerIdto the first seeded payer (Dazza Fittings). To point it at someone else instead:curl -X POST "$PROD_URL/admin/target" -H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" -d '{"payerId":"pyr_..."}'.
BASE_URL="$PROD_URL" ADMIN_KEY="$ADMIN_KEY" npm run smokeexport ANTHROPIC_API_KEY=<your-key>
# normal — $1,200, within limits, charge succeeds
BASE_URL="$PROD_URL" ADMIN_KEY="$ADMIN_KEY" ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
npx tsx agents/a2a.ts --scenario=normal
# blocked — $9,000, exceeds limit, Agent B relays the decline in natural language
BASE_URL="$PROD_URL" ADMIN_KEY="$ADMIN_KEY" ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
npx tsx agents/a2a.ts --scenario=blockedThe daily limit is cumulative across runs. If
--scenario=normalstarts declining on the daily limit after several demo runs, re-seed (step 3) to reset the spend counter.
curl -X POST "$PROD_URL/admin/call" \
-H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
-d '{"toNumber":"+614XXXXXXXX","payerId":"pyr_..."}'Sets the target payer, primes the ElevenLabs agent's dynamic_variables (payer name,
overdue balance, oldest overdue item, today's date, the agent's own spending
authority) directly on the outbound-call request, and places the call.
| Symptom | Cause / Fix |
|---|---|
Tool returns no_grant |
Grant missing/stale → re-seed (step 3) |
401 on /admin/* |
Wrong/missing ADMIN_KEY → check the Authorization: Bearer header matches the deployed secret |
charge_now → "Bank Account required…" |
Payer has no saved source → re-seed (or run add_bank_account for that payer first) |
/admin/recovered empty / $0.00 |
Payments not settled yet → use sandbox time-travel to advance settlement, or scope with ?payerId=<pyr_...> to include realtime charges |
GET /voice/payers shows $0 / duplicate names |
An orphaned duplicate payer record from earlier testing — Pinch doesn't enforce payer-name uniqueness; find and cancel its scheduled payments directly |
- ❌ No dependency updates (
npm install/npm updateof anything) - ❌ No refactors
- ❌ No new features
- ✅ Only fixes to a broken demo path, nothing else
All /admin/* routes require Authorization: Bearer <ADMIN_KEY>. All /voice/*
routes require X-Voice-Secret: <VOICE_SECRET>. POST /webhooks/pinch is
authenticated via Pinch's own pinch-signature header, not a shared secret.
/health is open.
curl -X POST "$PROD_URL/admin/payers" \
-H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
-d '{"name":"Kev Concreting","email":"kev@example.com","mobile":"0400111222"}'
# -> { "payerId": "pyr_...", "name": "Kev Concreting" }A past dueDate is allowed on purpose — it's how you raise a demo invoice that's
already overdue.
curl -X POST "$PROD_URL/admin/invoices" \
-H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
-d '{"payerId":"pyr_...","amountCents":15000,"description":"Overdue callout","dueDate":"2026-07-01"}'Which payer /voice/tool resolves to when a voice script omits payerId or passes
the literal string "TARGET".
curl "$PROD_URL/admin/target" -H "Authorization: Bearer $ADMIN_KEY"
curl -X POST "$PROD_URL/admin/target" \
-H "Authorization: Bearer $ADMIN_KEY" -H "content-type: application/json" \
-d '{"payerId":"pyr_..."}'See step 7 of the runbook above.
curl "$PROD_URL/admin/payments/status" -H "Authorization: Bearer $ADMIN_KEY"
# -> { "totalPayments": 22, "statuses": [
# { "status": "scheduled", "count": 18, "totalCents": 412300, "totalDisplay": "$4,123.00" },
# { "status": "transferred", "count": 4, "totalCents": 88000, "totalDisplay": "$880.00" } ] }Sums status === "transferred" payments. Prefers the webhook-fed snapshot
(source: "webhook-events") when one exists, falls back to a live Pinch scan
(source: "live-scan") otherwise. Without ?payerId=, the live-scan fallback walks
scheduled payments only (not realtime charges) — pass ?payerId=<pyr_...> to include
a specific payer's charge_now results too.
Runs the exact same tool.run → permissions.enforce() path as MCP. Response
always carries a speech string. Body: { agentId, tool, params } — params
may be nested or the body may be flat.
curl -X POST "$PROD_URL/voice/tool" \
-H "X-Voice-Secret: $VOICE_SECRET" -H "content-type: application/json" \
-d '{"agentId":"demo-agent","tool":"create_payment_plan","params":{
"payerId":"pyr_...","totalAmountCents":240000,"instalments":4,
"frequency":"fortnightly","startDate":"2026-07-25","description":"Overdue balance"}}'Response shapes: success → { ok: true, result, speech }; decline → { ok: false, declined, speech }; error → { ok: false, error, speech }.
Called by ElevenLabs before a call connects; returns dynamic_variables (payer name,
top overdue item, total overdue, days overdue, today's date, the agent's own
authority) for the agent's system prompt. Served from a 60-second KV cache (warmed by
/admin/target and /admin/call) so it responds well under ElevenLabs' latency
budget instead of waiting on a live Pinch fetch.
Call-start context for a specific payer, and a list of seeded debtors with overdue
totals — see src/voice.ts for full response shapes.
Registered once via POST https://api.getpinch.com.au/test/webhooks (Pinch has no
dashboard UI for this — it's API-only: { uri, eventTypes? }, returns a whsec_...
signing secret you store as PINCH_WEBHOOK_SECRET). Verifies the pinch-signature
header, writes a KV snapshot + audit entry for every event that references a payment.
| Binding | Key pattern | Purpose |
|---|---|---|
GRANTS |
grant:<agentId> |
Agent authority records |
GRANTS |
seedpayer:<name-slug> |
Name → stable payerId, so seed/reset reuse payers |
GRANTS |
demo:targetPayerId |
The payerId /voice/tool resolves "TARGET" to |
GRANTS |
init:context:<payerId> |
Cached /voice/init payload (60s TTL) |
GRANTS |
pinch:event:<eventId> |
Webhook delivery dedup log |
GRANTS |
pinch:payment:<paymentId> |
Latest settlement-status snapshot from webhooks |
SPEND |
spend:<agentId>:<YYYY-MM-DD> |
Daily spend counters |
AUDIT |
audit:<ISO-ts>:<rand> |
Append-only audit log (agent actions + webhook events) |
TOKENS |
pinch:token |
Cached Pinch OAuth token |
IDEMPOTENCY |
idem:plan:<sha256> |
create_payment_plan replay guard (10 min TTL) |
- Pinch OAuth:
client_idmust be the Application ID (app_...), stored inPINCH_PUBLISHABLE_KEY. The publishable key (pk_...) and merchant ID are not valid for OAuth. - Admin routes:
Authorization: Bearer <ADMIN_KEY>. - Voice routes:
X-Voice-Secret: <VOICE_SECRET>. - Pinch webhook:
pinch-signature: t=<unix_seconds>,v2=<hmac-sha256>(verified, not a shared secret). - MCP:
?agentId=<id>query param orx-agent-idheader.
- TypeScript strict +
noUncheckedIndexedAccess—tscmust pass clean. - Amounts are always integer cents — never floats, never dollars in code paths.
- Never log full bank account numbers — masked to last 3 digits everywhere.
- All errors are structured objects — no raw stack traces to clients (every route
handler call in the Worker entry point is
awaited inside the outer try/catch, specifically so this holds for async exceptions too, not just synchronous ones). - Sandbox only — base URL is always
https://api.getpinch.com.au/test.