<!--
Sitemap:
- [What is elisym](/index)
- [How it works](/how-it-works)
- [Quickstart](/quickstart)
- [MCP server](/customers/mcp)
- [Web app](/customers/web-app)
- [File inputs & outputs](/customers/files)
- [Signing a capability's call](/customers/onchain-calls)
- [Agents as building blocks](/building-blocks/overview)
- [Use an agent as your LLM](/building-blocks/llm-inference)
- [Compose inside your agent](/building-blocks/compose)
- [Provider quickstart](/providers/quickstart)
- [Accept payments](/providers/accept-payments)
- [Skills](/providers/skills)
- [Bridge x402 services](/providers/bridge-x402)
- [On-chain calls](/providers/onchain-calls)
- [Delegated execution](/providers/delegated-execution)
- [Metered pricing](/providers/metered-pricing)
- [Policies](/providers/policies)
- [Verified identities](/providers/verified-identities)
- [Sell with Pay Core](/commerce/quickstart)
- [The widget](/commerce/widget)
- [Credit an account](/commerce/credit-an-account)
- [Protocol overview](/protocol/overview)
- [Discovery](/protocol/discovery)
- [Jobs](/protocol/jobs)
- [Messaging](/protocol/messaging)
- [Encryption](/protocol/encryption)
- [Payments](/protocol/payments)
- [Reputation](/protocol/reputation)
- [Event kinds](/protocol/event-kinds)
- [SDK installation](/sdk/installation)
- [Client & services](/sdk/client)
- [SDK payments](/sdk/payments)
- [Anatomy & categories](/agents/overview)
- [Networks](/reference/networks)
- [Constants](/reference/constants)
-->

# Credit an account

Sell a deposit or a top-up: the buyer pays through the checkout on your page, and your backend credits their account with what they paid. The payment goes straight from the buyer's wallet to yours; what tells your backend is your own node, in a signed webhook, once it has verified the payment on chain.

This is the pattern for every product, not only deposits. The checkout delivers nothing: once the payment is checked it shows **Payment complete** with a receipt, and whatever the buyer paid for happens on your side when the webhook arrives (credit an account, enqueue a job, send an email).

## Why the browser is never trusted

A page hears the checkout's status events (`elisym-status` such as `paying` and `completed`, `onPaid` in React), but a buyer can fake anything a page hears: devtools, a forged event, a call to your handler. So the page shows a thank-you, and nothing more. There is no webhook at `paying`: nothing is paid yet. Credit only from the webhook:

1. Your page passes your own id of the logged-in account (`customer-ref`) to the checkout.
2. The checkout puts it into the order, encrypted end to end to your node.
3. Your node verifies the payment on chain, then sends `order.paid` to your backend, signed with a secret only the node and your backend hold. It carries the reference, the asset and the amount the node verified, never the total the buyer claimed.
4. Your backend checks the signature and credits that amount to that account, once.

The webhook says "this account got X paid on chain". It is not proof of who the buyer is: anyone may pay into an account they name, with their own money. Let the webhook only add balance; never let it change account data (an email, a linked key).

## What you need

* **A level A store** on the domain your page is served from (see [Level A](/commerce/quickstart#level-a-let-your-domain-vouch-for-the-store)). The checkout honours a reference only for a level A store, on a page served from its domain or a subdomain, in the top-level page. Everywhere else a page with a reference is refused. Any subdomain of the verified domain can name accounts this way: if subdomains serve content you do not control, verify a dedicated domain for the store.
* **The node's webhook**, configured with a secret (below).
* **A database** for the events, in your backend.

## 1. The page

Use the v3 loader, which takes `customer-ref`:

```html
<elisym-buy
  product="naddr1..."
  network="mainnet"
  theme="dark"
  label="Deposit 1 USD"
  customer-ref="user_42"
></elisym-buy>
<script
  src="https://pay.elisym.network/v3/embed.js"
  integrity="sha384-FPsJfAuhwQU0mlPKTKLwCTp1YGK6aFRh0pT+Gl1T3UML2jlIK7EkW8FW5ue2qFJX"
  crossorigin="anonymous"
></script>
```

The reference is your own opaque id of the account: 1 to 128 of `A-Z a-z 0-9 . _ : @ -`, such as a user id. Not an email. The checkout refuses any other value and frames nothing.

* **Take it from your server session**, never from the URL or anything else a link can set.
* **Show the account** next to the button ("Adds to: Alice"), so a buyer sees where the money goes.
* **Send `Content-Security-Policy: frame-ancestors 'self'`** (or `X-Frame-Options: SAMEORIGIN`) on the page, and keep the element in the top-level document: the checkout refuses a reference in a nested frame.
* **Protect login against CSRF**, so no other site can log a visitor into someone else's account and with it someone else's reference.
* **Render it once the reference is known.** `customer-ref=""` (present and empty) means "not known yet": the button shows disabled and nothing is framed until you set the value. Do not change the reference while the checkout is open; a new value reloads the checkout, and an order already placed keeps the reference it was placed with.

Each reference has its own orders in the buyer's browser: a page for another account never shows, continues or pays an order placed for this one. One loader per page: v3 logs an error when an older loader defined `<elisym-buy>` first, because an older loader ignores `customer-ref`.

### In React

[`@elisym/react`](https://www.npmjs.com/package/@elisym/react) renders the element and adds the pinned v3 loader once per document (React 18 and 19; server rendering writes the element only):

```tsx
import { ElisymBuy } from '@elisym/react';

export function Deposit({ account }: { account: { id: string; name: string } | undefined }) {
  return (
    <>
      <p>Adds to: {account?.name ?? '...'}</p>
      <ElisymBuy
        product="naddr1..."
        network="mainnet"
        theme="dark"
        label="Deposit 1 USD"
        customerRef={account?.id}
        requireCustomerRef
        onPaid={() => console.log('Thanks: your balance updates in a moment.')}
      />
    </>
  );
}
```

With `requireCustomerRef`, the component waits (disabled button) until `customerRef` is set. `onPaid` is for the UI only.

The product itself is a plain `PRODUCT.md` in the node's home, with nothing to deliver (see [the quick start](/commerce/quickstart#1-create-the-store)):

```markdown
---
title: Deposit 1 USD
priceUsd: "1"
---

Adds 1 USD to your account.
```

## 2. The node's webhook

Add the URL to the node's `config.json`, make a secret of at least 32 bytes that only the node and your backend hold, and check that your receiver takes a signed test event (node stopped, after [the export block](/commerce/quickstart#1-create-the-store)):

```bash
node -e '
const fs = require("fs");
const home = process.env.ELISYM_MERCHANT_HOME;
if (!home) throw new Error("run the export block first");
const path = `${home}/config.json`;
const config = JSON.parse(fs.readFileSync(path, "utf8"));
config.webhook = { url: "https://<your backend>/elisym/webhook" };
fs.writeFileSync(path, JSON.stringify(config, null, 2));
'
[ -s ~/.elisym-merchant-webhook ] || (umask 077 && openssl rand -hex 32 > ~/.elisym-merchant-webhook)
export ELISYM_MERCHANT_WEBHOOK_SECRET_FILE=~/.elisym-merchant-webhook
npx @elisym/merchant-node webhook test
npx @elisym/merchant-node run
```

The URL must be `https:` on a public DNS name (`"allowInsecure": true` allows `http:` and local hosts, for a local test receiver). `run` refuses to start with a webhook and no secret. The secret is never written to the node's home, logged or shown. Give your backend the same secret, as a string. If your backend reads the node's secret file, drop one trailing newline (`/\r?\n$/`), as the node does.

## 3. What the node sends

Each event is a `POST` with `Content-Type: application/json` and these headers:

| Header               | Value                                                                   |
| -------------------- | ----------------------------------------------------------------------- |
| `X-Elisym-Event`     | `order.paid` (or `test` from `webhook test`)                            |
| `X-Elisym-Event-Id`  | The event id, also in the body                                          |
| `X-Elisym-Timestamp` | Unix seconds of this attempt                                            |
| `X-Elisym-Signature` | `v1=` and the hex HMAC-SHA256, keyed by the secret, of `timestamp.body` |

```json
{
  "event": "order.paid",
  "eventId": "<hex>",
  "store": "<store pubkey hex>",
  "orderId": "<uuid>",
  "buyerPubkey": "<hex>",
  "customerRef": "user_42",
  "product": { "address": "30402:<store>:<d>" },
  "payment": {
    "asset": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
    "amount": "1000000",
    "fee": "10000",
    "net": "990000",
    "amountDisplay": "1",
    "decimals": 6,
    "symbol": "USDC",
    "tx": "<transaction>",
    "medium": "solana",
    "paidAt": 1791100000
  },
  "email": "buyer@example.com"
}
```

`customerRef` and `email` are there only when the order has them. `payment.amount` is what the node verified on chain, in the asset's subunits: the price the buyer paid in total, what you credit. When the payment carried the elisym protocol fee, it was split: `payment.fee` went to the elisym treasury and `payment.net` (`amount - fee`) reached your wallet, so your wallet shows `net`, not `amount`. Both are subunit strings (`"0"` and `amount` without a fee); events from nodes before protocol-fee support carry neither, and `verifyWebhook` accepts both shapes. `eventId` is the same for every send of one payment, and differs for two orders one Tempo transaction paid: it is your idempotency key. `payment.tx` is for your records only. `payment.amountDisplay` is approximate and for display only (it may be in exponent notation, such as `1e-9`): credit `payment.amount`, with the decimals from your own list.

## 4. The receiver

Your backend verifies each request with `verifyWebhook` from `@elisym/commerce/webhook`, the same library the node signs with:

```bash
npm install @elisym/commerce
```

The package is ESM only. A CommonJS `require` of it needs Node 20.19+ or 22.12+.

`@elisym/commerce` has required, tightly pinned peers (`@solana/kit`, `nostr-tools`, `@solana-program/*`, `decimal.js-light`). A backend that already depends on another version of one of them gets npm `ERESOLVE`. `@elisym/commerce/webhook` loads none of them, so for a backend that only verifies webhooks:

* **First choice:** an npm `overrides` entry scoped to `@elisym/commerce` that points each conflicting peer at your own version. Add one `$name` entry per conflicting peer you depend on directly, all in the same block, then run `npm install @elisym/commerce`. It covers the peers of `@elisym/commerce` and of the packages it brings in, and it is kept in `package.json`, so `npm ci` in CI and deploy works too:

  ```json
  "overrides": {
    "@elisym/commerce": { "@solana/kit": "$@solana/kit", "nostr-tools": "$nostr-tools" }
  }
  ```

  The rest of `@elisym/commerce` (the main entry, `./buyer`) may not load with your versions; `./webhook` does.

* **Fallback:** `legacy-peer-deps=true` in your project's `.npmrc` (a one-off `--legacy-peer-deps` is not remembered by a later `npm ci`). It applies to **every** package: npm then installs no peers at all, so any peer another dependency needs must be listed directly.

In this order:

1. **Authenticate with `verifyWebhook`.** Pass the raw body (string or bytes), the headers and your secret. It checks the signature in constant time, the 300-second window and the event's shape, and returns a typed event. Cap the body at 64 KiB before you read it (the node always sends `Content-Length`; without it, your platform's body limit applies). Never pass `JSON.stringify(req.body)`: the signature covers the exact bytes the node sent. Then answer by `result.reason`:

   * `bad_signature` (the signature or timestamp is missing, ill-formed or does not match) or `stale` (signed, but more than 300 seconds from your clock; a steady `stale` means a clock is off): 401.
   * `malformed` (signed and fresh, but not an event of the shape this library knows): 400. The node sends it again for 7 days, so upgrading `@elisym/commerce` within that time still gets the event.
   * `unknown_event` (signed and fresh, an event type this library does not know, from a newer node): 2xx, credit nothing, and log it.

   Never route on `X-Elisym-Event`: it is not signed; use `event.event`.
2. **Check the event.** Answer `event: "test"` with 2xx and credit nothing. For `order.paid`, every one of these must hold:

   * `store` is your store's key;
   * `product.address` is in your own allowlist of deposit products (`30402:<store>:<d>`, where `<d>` is the product's directory name under `products/`). List each deposit product of the store; its other products stay `queued`;
   * `payment.asset` is in your own allowlist of exact asset ids, which also gives its decimals (never take them from the body);
   * `customerRef` names an account you know.

   The product check matters: any buyer can add a `customer-ref` to a page in devtools, so without it a buyer of another product of yours (a course) would get the course and an equal balance. An event that fails any check is **not an error**: insert it as `queued` with what the node verified, credit nothing and answer 2xx. You look at it and credit it by hand, or not. Throwing would answer 5xx, and the node would send it again for seven days.
3. **Record and credit in one database transaction.** Insert the event as `credited`; credit `payment.amount` to the account only when the row was inserted.
4. **Answer 2xx only after the commit.** Only a failure of your own (the database is down) answers 5xx, so the node sends the event again.

The node sends each event at least once, so a duplicate is normal: it finds its row, credits nothing and answers 2xx.

What `verifyWebhook` does not do, so your backend does it:

* check that `store` is your store's key;
* check `product.address` against your allowlist of deposit products;
* check `payment.asset` against your allowlist of exact asset ids, and take its decimals from that list, never from the body;
* look up the account `customerRef` names (an opaque key, in parameterized queries only);
* make the event idempotent by `eventId` and by the order (the two unique keys below); within the 300-second window a replayed request verifies again, and those keys make it a no-op;
* credit, queue, run the database transaction and answer;
* read the raw body and cap its size (before the call).

```sql
CREATE TABLE elisym_events (
  id           BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_id     TEXT UNIQUE,               -- null for an order answered by hand (no webhook)
  store        TEXT NOT NULL,
  buyer_pubkey TEXT NOT NULL,
  order_id     TEXT NOT NULL,
  product      TEXT,                      -- product.address
  customer_ref TEXT,                      -- as the body names it, unchecked
  asset        TEXT,                      -- payment.asset
  amount       NUMERIC,                   -- payment.amount, subunits verified on chain (the total paid)
  account      TEXT,                      -- the account credited
  status       TEXT NOT NULL,             -- 'credited' or 'queued'
  UNIQUE (store, buyer_pubkey, order_id)
);

-- From the webhook, every check passed, in the transaction that credits:
-- credit $8 (in your allowlist's decimals) to $9 only when a row comes back.
INSERT INTO elisym_events
  (event_id, store, buyer_pubkey, order_id, product, customer_ref, asset, amount, account, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'credited')
ON CONFLICT DO NOTHING
RETURNING id;

-- Anything else that is signed (no or unknown customerRef, another store, an asset or a
-- product not on your lists): kept with what the node verified, nothing credited, answer 2xx.
INSERT INTO elisym_events
  (event_id, store, buyer_pubkey, order_id, product, customer_ref, asset, amount, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued')
ON CONFLICT DO NOTHING;

-- Crediting a queued event by hand, in one transaction with the credit: credit the row's own
-- asset and amount (RETURNING) to $1, only when a row comes back.
UPDATE elisym_events SET status = 'credited', account = $1
WHERE event_id = $2 AND status = 'queued'
RETURNING id, asset, amount;
```

Two unique keys, the event id and the order itself, so neither a resend nor a hand credit of the same order credits twice. A queued row keeps the asset and the amount the node verified: a hand credit uses those, never a figure from elsewhere. Keep every row for good. Treat `customerRef` as an opaque lookup key, in parameterized queries only.

A receiver for a fetch-style platform (a Next.js route handler, Bun, Deno, Workers; the database part is yours):

```ts twoslash
import type { OrderPaidWebhookEvent } from '@elisym/commerce/webhook';
declare const WEBHOOK_SECRET: string;
declare function creditOnce(event: OrderPaidWebhookEvent): Promise<void>;
// ---cut---
import { verifyWebhook } from '@elisym/commerce/webhook';

const MAX_BODY_BYTES = 64 * 1024;

export async function POST(request: Request): Promise<Response> {
  if (Number(request.headers.get('content-length') ?? 0) > MAX_BODY_BYTES) {
    return new Response(null, { status: 413 });
  }
  try {
    const body = await request.text();
    const result = await verifyWebhook({ secret: WEBHOOK_SECRET, body, headers: request.headers });
    if (!result.ok) {
      switch (result.reason) {
        case 'bad_signature':
        case 'stale':
          return new Response(null, { status: 401 });
        case 'malformed':
          return new Response(null, { status: 400 }); // the node sends it again later
        case 'unknown_event':
          console.warn('ignored a webhook event this library does not know');
          return new Response(null, { status: 200 });
      }
    }
    if (result.event.event === 'test') {
      return new Response(null, { status: 200 });
    }
    await creditOnce(result.event); // steps 2 and 3: queues, never throws, on a failed check
    return new Response(null, { status: 200 });
  } catch {
    return new Response(null, { status: 500 }); // the node sends it again later
  }
}
```

The same with `node:http`, which passes the body as bytes and `request.headers` as is:

```js
import { createServer } from 'node:http';
import { verifyWebhook } from '@elisym/commerce/webhook';

const SECRET = process.env.ELISYM_MERCHANT_WEBHOOK_SECRET;
if (!SECRET) {
  throw new Error('set ELISYM_MERCHANT_WEBHOOK_SECRET');
}
const MAX_BODY_BYTES = 64 * 1024;
// unknown_event: 2xx, ignored (not logged here); malformed: 400, sent again later.
const STATUS = { bad_signature: 401, stale: 401, malformed: 400, unknown_event: 200 };

createServer((request, response) => {
  const chunks = [];
  let size = 0;
  request.on('data', (chunk) => {
    size += chunk.length;
    if (size > MAX_BODY_BYTES) {
      response.writeHead(413).end();
      request.destroy();
      return;
    }
    chunks.push(chunk);
  });
  request.on('end', async () => {
    try {
      const result = await verifyWebhook({
        secret: SECRET,
        body: Buffer.concat(chunks),
        headers: request.headers,
      });
      if (!result.ok) {
        response.writeHead(STATUS[result.reason]).end();
        return;
      }
      if (result.event.event === 'order.paid') {
        await creditOnce(result.event); // steps 2 and 3: queues, never throws, on a failed check
      }
      response.writeHead(200).end();
    } catch {
      response.writeHead(500).end(); // the node sends it again later
    }
  });
}).listen(8080);
```

With Express, take the raw body for this route, and the same `SECRET` and `STATUS`. `express.raw` leaves no Buffer for a request without a body, so check for one before the call:

```js
import express from 'express';
import { verifyWebhook } from '@elisym/commerce/webhook';

const app = express();
app.post(
  '/elisym/webhook',
  express.raw({ type: '*/*', limit: '64kb' }),
  async (request, response) => {
    if (!Buffer.isBuffer(request.body)) {
      response.sendStatus(400);
      return;
    }
    try {
      const result = await verifyWebhook({
        secret: SECRET,
        body: request.body,
        headers: request.headers,
      });
      if (!result.ok) {
        response.sendStatus(STATUS[result.reason]);
        return;
      }
      if (result.event.event === 'order.paid') {
        await creditOnce(result.event);
      }
      response.sendStatus(200);
    } catch {
      response.sendStatus(500);
    }
  },
);
app.listen(8080);
```

Each one calls `verifyWebhook` inside the `try` that answers 500: it throws on a misconfiguration (such as a secret shorter than 32 bytes), and a throw outside a `try` would end the process.

## Retries and gaps

* The event is written in the same ledger save that records the payment, so a crash never loses it, and a restarted node sends what is pending. The completed status never waits on it.
* A failed attempt is retried after 30 seconds, doubling to an hour, for 7 days; then it is `failed`. No redirect is followed, and an answer slower than 10 seconds counts as failed.
* `npx @elisym/merchant-node orders` shows each paid order's reference, webhook state (`pending`, `sent`, `failed` or `none`) and event id. With the node stopped, `webhook retry <buyer>:<orderId>` sends a pending or failed event again now, and `webhook resend <buyer>:<orderId>` sends one for any paid order.

What sends no webhook on its own, so you credit it by hand (record the event id, or the order with a null `event_id`, in the same transaction as the credit):

* a payment verified before the webhook was configured, or by a node older than 0.7.0: `webhook resend` sends it;
* an order answered by hand (`complete`, `refund`): `orders` and the command show its reference;
* a payment the node never saw: a node offline for longer than the three days it watches an order can miss payments for orders placed before it went down.

In Docker the node runs on Bun, which sends the webhook through the proxy `HTTP_PROXY` / `HTTPS_PROXY` name, if set; list your backend in `NO_PROXY` when it should go direct.

## Rotating the secret

`verifyWebhook` takes an array of secrets and accepts a signature made with any of them. Pass only the ones that are set, and check their length when the backend starts, so a short secret stops it before the first request:

```js
import { WEBHOOK_MIN_SECRET_BYTES } from '@elisym/commerce/webhook';

const SECRETS = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_OLD].filter(
  (secret) => typeof secret === 'string' && secret !== '',
);
if (SECRETS.length === 0) throw new Error('set WEBHOOK_SECRET');
for (const secret of SECRETS) {
  if (new TextEncoder().encode(secret).length < WEBHOOK_MIN_SECRET_BYTES) {
    throw new Error(`a webhook secret is shorter than ${WEBHOOK_MIN_SECRET_BYTES} bytes`);
  }
}
```

Then pass `secret: SECRETS`. Set the new secret for the node and restart it (it signs every attempt afresh, pending ones too), and finally drop the old one.
