---
title: Everything Around the Charge
date: '2026-07-10'
description: 'The request that takes a payment is four lines. Everything that makes it safe to build a business on, so a retry never double-charges and a dropped webhook never loses an order, is the actual work. How I shaped Paybit''s crypto payments API on the pattern Stripe proved.'
author: Pablo Fernandez
tags:
  - APIs
  - Payments
  - Crypto
  - Idempotency
  - Webhooks
source: 'https://www.pablofr.com/blog/anatomy-of-a-payments-api'
---
The request that charges someone is almost nothing. You POST an amount and a merchant, you get back an object with an id, you are done. If that were the whole job, a payments API would be a weekend project. The reason it is not is everything that has to be true around that one request: that sending it twice charges once, that the state it reports can be trusted, that the merchant finds out the payment happened even if their server was asleep when it did, and that when something goes wrong the error is something a program can act on rather than a sentence a human has to read.

This is a companion to [the last post](/blog/non-custodial-payment-gateway), which was about where the money goes. This one is about the shape of the [API](https://docs.stripe.com/api) in front of it: the small set of design decisions that make a payments API safe to hand to other developers. I built [Paybit](/blog/non-custodial-payment-gateway) on the pattern [Stripe](https://docs.stripe.com/api) proved, not because copying is easy, but because that shape is not decoration. It is a set of answers to questions that will otherwise cost you money in production.

## The dangerous part is the second request

A payments API is a distributed-systems problem wearing a REST costume. The network drops responses, so clients retry. Two things race: the buyer's wallet confirming on-chain and your poller checking for it. A webhook fires while the receiver is mid-deploy. Every one of these is an ordinary web-service problem, and every one of them, once money is involved, becomes a way to charge someone twice, lose an order, or report a payment that did not happen.

So the question the whole design answers is narrow and unglamorous: how do you make an interface that feels like plain CRUD but behaves like a ledger? You cannot bolt that on later. It has to live in the shape of the resources themselves.

## Copy the shape, it encodes the answers

I did not invent a resource model. Paybit's surface is Stripe-shaped on purpose: a small vocabulary of nouns, each a JSON object that names its own type, all POSTed to create and GET to read.

| Resource | id prefix | what it is |
| --- | --- | --- |
| payment_intent | `pi_` | one amount to collect, as a state machine |
| merchant | `merch_` | a seller account (addresses + fee) |
| refund | `re_` | funds returned to a payer |
| event | `evt_` | an immutable record of a state change |
| webhook_endpoint | `we_` | a URL events are delivered to |
| customer | `cus_` | a payer identity |
| subscription | `sub_` | recurring billing |
| checkout | `co_` | one order payable by several methods |
| payment_link | `plink_` | a reusable, multi-payer URL |
| payout | `po_` | an outgoing payment |

Three small conventions do a lot of quiet work. Every object carries an `object` field naming its type, so a client never has to guess what it is holding. Every list is the same envelope, `{ object: "list", url, has_more, data: [...] }`, so pagination is learned once and works everywhere. And every id is a prefixed, time-ordered [UUIDv7](https://www.rfc-editor.org/rfc/rfc9562): the prefix tells you the type at a glance (`pi_`, `re_`, `evt_`), the timestamp bits mean ids sort by creation and index well, and the random bits keep them unguessable. A payment id you can read in a log and never predict from the outside.

```json:payment_intent.json
{
  "id": "pi_9f2c8a…",
  "object": "payment_intent",
  "status": "succeeded",
  "merchant": "merch_1a…",
  "chain": "base",
  "amount": "5000000",
  "amount_usd": "5.00",
  "metadata": { "order_id": "4172" }
}
```

```
   POST /v1/payment_intents           one request, one resource
      │
      ▼
   ┌────────────────────────────┐
   │  payment_intent   pi_9f2…  │ requires_payment_method
   └────────────────────────────┘
      │   buyer pays · chain confirms
      ▼
   ┌────────────────────────────┐
   │  the same intent           │ succeeded   (now emits an event)
   └────────────────────────────┘
```

*One POST creates one resource. Nothing you send afterward sets its status: the buyer's payment and the chain's confirmations move it, and only then does it emit an event.*

## A payment intent is a state machine, not a row you edit

This is the single most important decision, and it is where a naive CRUD API quietly bleeds money. The status of a payment is not a column you PATCH. It is a state machine, and the transitions are earned, never assigned. An intent is born `requires_payment_method`. When the watcher sees a matching payment on-chain it becomes `processing`. When that payment is buried under enough confirmations it becomes `succeeded`, or `requires_action` if it came up short. Expire unpaid and it is `canceled`.

The API simply does not expose a way to set that field. You can create an intent, attach metadata to it, or cancel it while it is still unpaid. Everything else is moved by settlement, and each of those writes is a guarded conditional update ("set succeeded WHERE status is not already terminal"), so a late, duplicated, or out-of-order event can never resurrect a finished payment or overwrite it with a second one.

```
   what a caller may write           what only settlement moves
   ──────────────────────            ──────────────────────────
   create an intent                  status: requires_payment_method
   attach / edit metadata            status: processing
   cancel an UNPAID intent           status: succeeded
                                     status: requires_action
                      │
                      ▼
   status is never set by hand; the API refuses an illegal transition
```

*The API is deliberately small about what a caller may change. The money-state is moved only by on-chain settlement, so it is earned, never assigned.*

## The same request twice is the same result

Clients retry. They must, because the network eats responses, and the client cannot tell "the request failed" from "the response was lost on the way back". For a payment, those two cases are opposite: one means try again, the other means you already charged them. The way out is to let the client decide the request's identity, not the server. Every write carries an `Idempotency-Key` (the SDK attaches a fresh one per call), and the server remembers, for a while, the first response it gave that key.

A retry with the same key and the same body replays the first response byte for byte, with an `Idempotent-Replayed: true` header so you know it happened. A reuse of the key with a *different* body is refused with a `409`, because that is not a retry, it is a bug: the same idempotency key cannot mean two different charges. The key is also scoped to the caller, so two merchants who both pick `"abc123"` never collide.

```
   POST /v1/payment_intents          POST again (same key, same body)
   Idempotency-Key: abc123           Idempotency-Key: abc123
        │                                 │
        ▼                                 ▼
   creates  pi_9f2…                  returns the SAME pi_9f2…
   charged once                      Idempotent-Replayed: true

   same key, but a DIFFERENT body   ──▶   409  idempotency_key_reused
```

*A retry with the same key and body replays the first result; the same key with a different body is a 409. A dropped response can never become a second charge.*

See [Stripe's idempotency design](https://docs.stripe.com/api/idempotent_requests) for the canonical version of this. The one honest caveat is below.

## Errors are data, not prose

An error from a payments API is something a program has to branch on, so it has to be structured, stable, and safe. Every failure is the same envelope: a `type` (the broad class), a `code` (the specific, stable machine string), a human `message`, and the `param` that was wrong. You switch on `code`, you show the `message`, you highlight the `param`.

Two rules keep it trustworthy. The `code` values are a closed, documented set, so client logic written against them does not break when a message is reworded. And an internal error never leaks: a database driver's error text is logged server-side and the client gets a generic `api_error`, because the shape of your storage is nobody's business and a raw error string is how details escape.

```json:error.json
{
  "error": {
    "type": "invalid_request_error",
    "code": "resource_missing",
    "message": "payment_intent not found",
    "param": "id"
  }
}
```

## The log is the truth, the webhook is only a courier

A merchant needs to know the instant a payment succeeds, and polling for it does not scale. So every money-state change writes one row to an append-only `events` log, holding the resource exactly as it looked at that moment. That log is the source of truth, and it is queryable: `GET /v1/events` will replay history whether or not any webhook ever fired. Delivery is a separate, best-effort layer on top. This split matters, because it means a merchant who was down for an hour has not lost anything, they read the log.

Delivery itself is the part with sharp edges, because it POSTs to a URL a merchant controls, over the open internet.

```
   a state change
      │
      ▼
   event written to the append-only log   (evt_…, GET /v1/events)
      │
      ▼
   delivery queue  ──▶  POST to your URL, HMAC-signed
                        │
                        ├─  2xx   ──▶  done
                        └─  fail  ──▶  retry  10s · 30s · 1m · 5m · 30m · 2h · 6h
```

*A state change writes one immutable event; delivery is a separate, retried layer on top. The log is the truth, the webhook is just a courier that may need to knock more than once.*

Three things make that delivery safe. Each POST is signed with [HMAC-SHA256](https://en.wikipedia.org/wiki/HMAC) over the timestamp and the raw body, the [same scheme as Stripe](https://docs.stripe.com/webhooks), so the receiver can prove the event came from Paybit and is not a forgery or a replay. The delivery client is hardened against [SSRF](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery): it refuses at dial time to connect to any private, loopback, or link-local address (including the cloud-metadata IP), and it never follows redirects, so a merchant URL cannot bounce the request into an internal network. And failures retry on a widening schedule (10s, 30s, a minute, up to hours) before giving up, which makes delivery reliably at-least-once.

On the receiving side, verifying is a few lines, and the one rule that trips everyone up is to verify the *raw* body: parse-then-reserialize changes bytes and the signature fails.

```typescript:webhook.ts
import { verifyWebhook } from "paybit";

// req.rawBody is the UNPARSED body; a signature is over exact bytes, not JSON.
const event = await verifyWebhook(req.rawBody, headers["paybit-signature"], secret);
if (event.type === "payment_intent.succeeded") {
  fulfill(event.data.object.metadata.order_id);
}
```

## Keys that are hashed, scoped, and shown once

Auth is a bearer token, and the details are the usual boring-and-therefore-correct ones. There is a platform key (the admin, compared in constant time to resist [timing attacks](https://en.wikipedia.org/wiki/Timing_attack)) and per-merchant keys (`pk_…`), and a merchant key is only ever stored as its SHA-256 hash, shown to the merchant exactly once at creation and never again. A merchant principal is scoped to its own resources, so a merchant key simply cannot read another merchant's payments even by guessing an id. Webhook signing secrets are the one credential stored in plaintext, because the server has to sign with them, and they are likewise revealed once.

## A client you do not hand-write

All of the above is invisible if the SDK is good, and the SDK is not glue a human maintains, it is a thin, typed layer generated over the exact surface. Resources are objects (`paybit.paymentIntents.create`), the return types are the real objects, and the safety is built in: an idempotency key per write, automatic retries with jittered backoff on the transient statuses (`429`, `503`, and friends), and `Retry-After` honored. The happy path is two lines, and the correctness is not something the caller has to remember.

```typescript:checkout.ts
const paybit = new Paybit({ baseUrl, apiKey: process.env.PAYBIT_API_KEY });

// A fresh Idempotency-Key is attached per write, so a network retry is safe.
const intent = await paybit.paymentIntents.create({
  merchant, order_id: "4172", chain: "base", amount: "5000000",
});
```

## The honest limits

The shape is good, and it still has edges I would rather name than hide.

- **Idempotency is per-process, not clustered.** The replay cache lives in memory. Run several API replicas behind a balancer and a retry that lands on a different replica is not deduplicated by this layer. The business-level uniqueness (an order id is unique per merchant, so a duplicate create returns a `409`) backstops the case that actually matters, but the general guarantee is per-process until it is backed by the shared database.
- **Webhooks are at-least-once, never exactly-once.** A delivery can succeed and its acknowledgement still be lost, so the same event can arrive twice. Consumers must dedupe on the event id. That is a property of every honest webhook system, but it is on the receiver, and it is worth saying out loud.
- **Freshness is bounded by the chain.** Settlement is only as fast as confirmations plus the poll interval, so "succeeded" is real but not instant. There is no card-style instant approval, by design, and I wrote about why in [the non-custody post](/blog/non-custodial-payment-gateway).
- **Versioning is a header, not a URL.** A `Paybit-Version` rides on every response. That keeps URLs clean but means version negotiation is a convention, and disciplined change management, rather than a hard wall.

## The surface is small on purpose

Step back and the API is almost boring. A handful of nouns, two verbs, JSON in and JSON out. That plainness is the whole point, and it is expensive to achieve. What makes it a payments API and not a to-do list is not the endpoints, it is the set of guarantees welded under each one: a write that is safe to retry, a status no caller can forge, an event log you can replay, an error you can branch on, a key that leaks nothing.

You are not really designing endpoints. You are designing what cannot go wrong, and then hiding all of it behind a request that looks like four easy lines. The four lines are the promise. Everything around the charge is the part you are actually paid to get right.

## References

- [Stripe API reference, the shape most of this follows](https://docs.stripe.com/api)
- [Stripe: idempotent requests](https://docs.stripe.com/api/idempotent_requests)
- [Stripe: webhook signatures](https://docs.stripe.com/webhooks)
- [RFC 9562: UUID formats (UUIDv7 is time-ordered)](https://www.rfc-editor.org/rfc/rfc9562)
- [OWASP: Server-Side Request Forgery](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery)
