PayEngine Logo

PayEngine

by metronomiQ

Overview

Welcome to the Metronomiq API documentation. Build time-based payment solutions with Lightning Network integration.

Lightning Network Integration

Native Lightning support with millisatoshi precision

Time-Based Payments

Stream payments by the second via WebSocket

Base URLs

Production: https://payengine.metronomiq.io/api
Test: https://payengine-test.metronomiq.io/api

⚠️ Note: The test environment runs on Bitcoin Regtest and Lightning Network Regtest for development and testing purposes.

Authentication

All API requests require authentication using your API key.

Authentication Header

x-api-key: your_api_key_here

Example Request

curl -X GET https://payengine.metronomiq.io/users \
  -H "  : your_api_key_here"

API Key Rotation

For security, rotate your API key regularly. Use both old and new keys during the rotation process.

curl -X POST https://payengine.metronomiq.io/applications/rotate-key \
  -H "Authorization: RotateKey your_rotate_key"

Getting Started

The path from an empty account to a first billed usage, then a Lightning payout. Each step links to its reference section — this page is the map, not the manual.

Before you start: your three credentials

When your application is created, three secrets are returned once — and never again. Store them before closing the response.

  • apiKey — format appId:secret, sent as the x-api-key header on every REST call.
  • rotateKey — the only way to rotate the API key later.
  • webhookSecret — HMAC key used to verify incoming webhooks.
1

Publish your RSA public key

Streaming sessions open with a JWT you sign yourself. PayEngine only ever holds the public half — without it, every socket is refused.

Set Public Key →

2

Create your users

Both sides of the transaction: the one who pays, and the one who receives. A wallet is created automatically for each. You identify them by your own IDs.

Create User →  ·  Set the commission rate →

3

Price the usage, and attach it to a resource

A payment policy says what one step of usage costs, how long a step lasts, and who receives the payment. A resource carries one or more policies — one per beneficiary — and the price of a step is their sum. Every session opens on a resource.

Create Policy →  ·  Link Resource →  ·  Split payment →

4

Fund the payer

The only place real money enters the system. You issue a Lightning invoice, it gets paid from any wallet, and the internal balance is credited. Everything after this is settled internally.

Create Deposit →  ·  Retrieve a pending invoice →  ·  Check the balance →

5

Open the metered session

You mint a short-lived JWT on your own server, naming the resource and the payer, then open the WebSocket. The engine bills one step at a time — the full price of the step, split between the resource's policies — and closes the socket when funds run out.

Setup Process →  ·  Connection & close codes →

6

Read what was billed, and pay out

A debit event is what one policy received in one session, fees included — a session on a resource with three policies produces three. Sync them incrementally rather than re-reading everything, then send a balance back out over Lightning.

Incremental Sync →  ·  Preview Withdrawal →  ·  Execute Withdrawal →

Six things that break a first integration

  • The stream URL has no /api prefix. REST is /api/..., the WebSocket is /stream.
  • Every amount comes back as a string in millisatoshis. Requests take whole sats; responses return msat strings. Never parse them into a float — JavaScript numbers lose precision long before your ledger does.
  • Browsers are rejected unless their origin is allowlisted. A browser always sends an Origin header; if it is not allowlisted the socket closes with 4403, before the token is even read. Server-side clients send no Origin and are never affected.
  • Stream tokens must carry exp and iat. A token without exp is refused with 4002; one older than the server's maximum age with 4003.
  • Tips use currency "SAT", singular. "SATS" is rejected by validation.
  • The balance response is nested. It is { balance: { balanceMsat, balanceSat } }, not a flat object.

Idempotency

Every endpoint that moves money requires an Idempotency-Key header. It guarantees that retrying a request never moves the same funds twice.

Required header

The header is mandatory on the three money-moving routes below. A request without it is rejected with 400 IDEMPOTENCY_KEY_REQUIRED — no money is moved.

Idempotency-Key: 3f1c7a02-8b45-4d21-9a6e-5c0f2b71d8e4

Routes that require the header

Route Effect
POST /users/{id}/tips Internal wallet-to-wallet transfer
POST /users/{id}/deposit Issues a Lightning invoice
POST /users/{id}/withdraw Pays a Lightning invoice — irreversible

Read endpoints and POST /users/{id}/withdraw/preview move no funds and do not require the header.

Key format

  • 16–128 characters
  • Allowed characters: A-Z a-z 0-9 . _ : -
  • Sent exactly once per request (a repeated header is rejected)
  • A UUID v4 is the recommended default

Keys are scoped per application and per operation, so they never collide with another integrator, and the same value may safely be reused across a tip and a withdrawal.

Replay behaviour

Reuse the exact same key to retry the exact same operation. The server does not move a single millisatoshi a second time: it returns the original movement, flagged with replayed: true.

curl -X POST https://payengine.metronomiq.io/users/user_123/tips \
  -H "x-api-key: your_api_key_here" \
  -H "Idempotency-Key: tip:order-8842" \
  -H "Content-Type: application/json" \
  -d '{"toUserId":"user_456","amount":1000,"currency":"SAT"}'

Response to a replay

201 Created
{
  "transactionId": "transaction-uuid-here",
  "fromUserId": "user_123",
  "toUserId": "user_456",
  "amountMsat": "1000000",
  "appFeeMsat": "100000",
  "status": "SUCCESS",
  "replayed": true
}

Never reuse a key for a different operation. For a withdrawal the server rejects it with 409 IDEMPOTENCY_KEY_REUSED; elsewhere it would treat it as a replay and the second payment would silently never happen. Derive keys from your own business identifiers, for example tip:{orderId}.

Withdrawal deduplication (server-side)

A withdrawal is the only irreversible way funds leave PayEngine, so it carries a second guarantee that does not depend on your key at all: a bolt11 invoice can only ever be paid once. Both guarantees are enforced by the database, so they hold under concurrent requests.

  • Same key, same invoice — returns the state of the existing withdrawal with replayed: true, even while payments are paused. status is PENDING while the payment is still in flight, then SUCCESS — or FAILED if it failed and the reservation was refunded.
  • Different key, same invoice — rejected with 409 WITHDRAWAL_ALREADY_IN_FLIGHT. Retrying will not help.
  • Same key, another user or another invoice — rejected with 409 IDEMPOTENCY_KEY_REUSED. The state of another withdrawal is never returned.
  • Failed withdrawal — the reservation was refunded, so the same invoice may legitimately be retried, with a new key. If that retry cannot get a definitive answer from the node, it ends in 504 WITHDRAWAL_OUTCOME_PENDING and may need a manual review.

Replayed withdrawal

201 Created
{
  "transactionId": "transaction-uuid-here",
  "paymentHash": "payment-hash-here",
  "amountMsat": "20000",
  "feeMsat": "1000",
  "totalDebitedMsat": "21000",
  "status": "PENDING",
  "completedAt": null,
  "replayed": true
}

Errors

HTTP Code When
400 IDEMPOTENCY_KEY_REQUIRED Header missing on a money-moving route
400 IDEMPOTENCY_KEY_INVALID Wrong length, forbidden characters, or header sent twice
409 WITHDRAWAL_ALREADY_IN_FLIGHT This bolt11 invoice is already being paid, or has already been paid
409 IDEMPOTENCY_KEY_REUSED A withdrawal key already used for another user or another invoice

Using the JavaScript SDK

From version 2.1, the SDK sends the header automatically on every money call. Pass your own key to make an application-level retry idempotent.

await client.user.tip({
  sourceExternalId: 'user_123',
  targetExternalId: 'user_456',
  amount: 1000,
  currency: 'SAT',
  idempotencyKey: `tip:${orderId}`, // optional but recommended
})

Application Setup

Configure your application for PayEngine integration.

Set Public Key

PATCH /applications/public-key

Required for WebSocket Streaming

You must upload your public key before using WebSocket streaming. PayEngine uses this to verify JWT tokens generated with your private key.

Upload your RSA public key in PEM format.

Request

{
  "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----"
}

Response

200 OK

Set Webhook URL

PATCH /applications/webhook-url

Configure the URL where PayEngine will send webhook notifications.

Request

{
  "url": "https://your-app.com/webhooks/payengine"
}

Response

200 OK

Refresh Webhook Secret

GET /applications/refresh-webhook-secret

Generate a new webhook signing secret for security.

Response

200 OK
{
  "newWebhookSecret": "new_webhook_secret"
}

Rotate API Key

POST /applications/rotate-key

Generate a new API key for enhanced security. Use the Authorization header with your rotate key.

Headers

Authorization: RotateKey your_rotate_key

Response

200 OK
{
  "newApiKey": "pe_prod_abc123..."
}

User Management

Manage users within your application.

Overview

What is a User?

A User in PayEngine represents an individual within your application's ecosystem who can send and receive payments. Each user gets their own managed Lightning wallet hosted by PayEngine.

External ID

The externalId is your unique identifier for this user in your system. This allows you to map PayEngine users to users in your database.

Managed Wallet

Each user automatically gets a Lightning wallet managed by PayEngine. You don't need to handle private keys or Lightning complexity.

Instant Payments

Users can send and receive Lightning payments instantly with millisatoshi precision. Perfect for micropayments and streaming money.

Create User

POST /users

Create a new user with an internal wallet.

Request

{
  "externalId": "user_123"
}

Response

201 Created
{
  "id": "user-uuid-here",
  "externalId": "user_123",
  "feePercent": 10,
  "applicationId": "application-uuid-here",
  "walletId": "wallet-uuid-here",
  "kind": "STANDARD"
}

Reserved identifiers. Every externalId starting with __payengine belongs to PayEngine: creating, renaming to, updating or deleting such a user is refused with 400 USER_RESERVED. Each application has one reserved user, __payengine_lost_funds__, which receives the share of a beneficiary whose wallet is unavailable (see Split Payment). It is never returned by GET /users, but GET /users/{id} and its balance can read it; it never pays and is never a beneficiary. User objects carry kind: STANDARD for your users, LOST_FUNDS for that one.

Get All Users

GET /users

List all users in your application.

Response

200 OK
[
  {
    "id": "user-uuid-here",
    "externalId": "user_123",
    "feePercent": 10,
    "applicationId": "application-uuid-here",
    "walletId": "wallet-uuid-here",
    "kind": "STANDARD"
  },
  {
    "id": "user-uuid-2-here",
    "externalId": "user_456",
    "feePercent": 15,
    "applicationId": "application-uuid-here",
    "walletId": "wallet-uuid-2-here",
    "kind": "STANDARD"
  }
]

Get User

GET /users/{id}

Get a specific user by their external ID.

Path Parameters

id: User external ID (e.g., "user_123")

Response

200 OK
{
  "id": "user-uuid-here",
  "externalId": "user_123",
  "feePercent": 10,
  "applicationId": "application-uuid-here",
  "walletId": "wallet-uuid-here",
  "kind": "STANDARD"
}

Tipping

POST /users/{id}/tips

Send a tip from one user to another user.

Idempotency-Key required. This endpoint moves money. See the Idempotency section for the key format and replay behaviour.

Path Parameters

id: User external ID of the sender (e.g., "user_123")

Request

{
  "toUserId": "user_456",
  "amount": 1000,
  "currency": "SAT"
}

Request Body Parameters

  • toUserId (string, required) - External ID of the recipient user
  • amount (number, required) - Amount to tip in satoshis
  • currency (string, required) - Must be "SAT"

Response

201 Created
{
  "transactionId": "transaction-uuid-here",
  "fromUserId": "user_123",
  "toUserId": "user_456",
  "amountMsat": "1000000",
  "appFeeMsat": "100000",
  "status": "SUCCESS"
}

Create Deposit Invoice

POST /users/{id}/deposit

Generate a Lightning invoice for user deposits.

Idempotency-Key required. Replaying the same key returns the invoice already issued instead of putting a second bolt11 into circulation.

Path Parameters

id: User external ID (e.g., "user_123")

Request

{
  "amount": 1000
}

Response

201 Created
{
  "id": "deposit-uuid-here",
  "paymentHash": "payment-hash-here",
  "request": "lnbc1000...",
  "amountSat": "1000",
  "amountMsat": "1000000",
  "description": "Deposit for user user_123",
  "status": "PENDING",
  "expiresAt": "2024-01-01T12:30:00Z"
}

Status Values

  • PENDING - Invoice created, waiting for payment
  • PAID - Invoice has been paid successfully
  • EXPIRED - Invoice has expired without payment

If the Lightning node cannot process payments, the request fails with 503 LIGHTNING_UNAVAILABLE and no invoice is created: nothing can be paid, so it is safe to retry later with the same Idempotency-Key.

Preview Withdrawal

POST /users/{id}/withdraw/preview

Preview fees and check balance before withdrawal.

Path Parameters

id: User external ID (e.g., "user_123")

Request

{
  "invoice": "lnbc1000n1pwg..."
}

Response

200 OK
{
  "invoiceAmountMsat": "1000000",
  "invoiceAmountSat": "1000",
  "estimatedFeeMsat": "1001",
  "estimatedFeeSat": "1",
  "totalRequiredMsat": "1001001",
  "totalRequiredSat": "1001",
  "currentBalanceMsat": "45900",
  "currentBalanceSat": "45",
  "hasSufficientFunds": false,
  "description": "Payment for services",
  "destination": "02152fe11f478063aab5a324d0c244b268205d1edd577b0bd95f4c935b5ff71966",
  "expiresAt": "2025-09-05T21:35:17.000Z"
}

Execute Withdrawal

POST /users/{id}/withdraw

Pay a Lightning invoice on behalf of a user.

Idempotency-Key required. Independently of your key, a bolt11 invoice can only be paid once: a second attempt returns the existing withdrawal (replayed: true) or 409 WITHDRAWAL_ALREADY_IN_FLIGHT. Reusing a key for another user or another invoice returns 409 IDEMPOTENCY_KEY_REUSED.

Path Parameters

id: User external ID (e.g., "user_123")

Request

{
  "invoice": "lnbc1000n1pwg..."
}

Response

200 OK
{
  "paymentHash": "payment-hash-here",
  "amountMsat": "1000000",
  "feeMsat": "1000",
  "totalDebitedMsat": "1001000",
  "estimatedFeeMsat": "800",
  "actualFeeMsat": "1000",
  "confirmedAt": "2024-01-01T12:00:00Z"
}

Where the money is, answer by answer

A withdrawal first reserves the amount plus the estimated network fee on the user's balance, then pays the invoice.

  • 200 — paid. The balance was debited by totalDebitedMsat; the unused part of the fee reserve was returned.
  • 200 with replayed: true — same Idempotency-Key: the current state of that withdrawal (PENDING, SUCCESS or FAILED).
  • 400 INVOICE_EXPIRED, 503 LIGHTNING_UNAVAILABLE — refused before anything was reserved.
  • 502 WITHDRAWAL_PAYMENT_FAILED — the node confirmed the failure; the reservation is back on the balance.
  • 504 WITHDRAWAL_OUTCOME_PENDING — the outcome is not known yet; the amount stays reserved. Show the user that the withdrawal is being verified, and replay with the same key to follow it.

Pending withdrawals are re-checked every 5 minutes: completed when the node confirms the payment, refunded when the node confirms that this withdrawal's payment failed. A withdrawal is never refunded automatically when the node has no record of the payment, or when the invoice was already tried by an earlier withdrawal and the reported failure may belong to that earlier attempt — it goes to manual review by the PayEngine operators, because refunding a payment that actually left would pay it twice.

Get Balance

GET /users/{id}/balance

Check a user's wallet balance.

Path Parameters

id: User external ID (e.g., "user_123")

Response

200 OK
{
  "balance": {
    "balanceMsat": "5000000",
    "balanceSat": "5000"
  }
}
GET /api/users/{externalId}/deposit

C'est ici qu'on retrouve une invoice déjà émise. La bolt11 n'est renvoyée qu'une seule fois par POST /deposit : si l'utilisateur ferme son écran avant de payer, cette route est le seul moyen de la lui re-présenter pendant ses 30 minutes de validité.

Renvoie les 50 invoices les plus récentes de l'utilisateur, invoiceRequest (la bolt11) inclus.

Query parameters

payableOnlytrue ⇒ uniquement celles qui peuvent encore être payées (statut PENDING et non expirées). Le filtre est appliqué par le serveur — n'implémentez pas la règle d'expiration de votre côté, elle finirait par diverger de la sienne.
statusPENDING | PAID | EXPIRED
curl "https://payengine.metronomiq.io/api/users/user_123/deposit?payableOnly=true" \
  -H "x-api-key: $PAYENGINE_API_KEY"
[
  {
    "id": "invoice-uuid",
    "paymentHash": "a1b2c3…",
    "amountMsat": "500000",
    "invoiceRequest": "lnbc…",
    "description": "Top-up",
    "status": "PENDING",
    "createdAt": "2026-08-10T10:00:00.000Z",
    "expiresAt": "2026-08-10T10:30:00.000Z",
    "isPayable": true
  }
]

isPayable est calculé par le serveur, avec la même horloge que celle qui fera expirer l'invoice. Ne le recalculez pas depuis expiresAt et l'heure du client : sa dérive produirait des faux positifs.

Get Deposit Status

GET /users/{id}/deposit/{paymentHash}/status

Check the status of a deposit invoice.

Path Parameters

id: User external ID (e.g., "user_123")
paymentHash: Payment hash from deposit invoice

Response

200 OK
When deposit exists (PENDING/PAID):
{
  "status": "PAID",
  "amountMsat": "1000000",
  "amountSat": "1000",
  "description": "Deposit for user user_123",
  "expiresAt": "2024-01-01T12:30:00Z",
  "createdAt": "2024-01-01T12:00:00Z",
  "walletId": "wallet-uuid-here"
}
When deposit is expired:
{
  "status": "EXPIRED",
  "amountMsat": "1000000",
  "expiresAt": "2024-01-01T12:30:00Z"
}

EXPIRED is not always final: a payment accepted by the node just before expiry can settle shortly after it. Such an invoice is still credited — exactly once — and its status becomes PAID. Keep polling for about an hour after expiry if the user says they paid.

When deposit not found:
{
  "status": "NOT_FOUND"
}

Response Fields

  • status - Always present
  • amountMsat? - Present when status is PENDING, PAID, or EXPIRED
  • amountSat? - Present when status is PENDING or PAID
  • description? - Present when status is PENDING or PAID
  • expiresAt? - Present when status is PENDING, PAID, or EXPIRED
  • createdAt? - Present when status is PENDING or PAID
  • walletId? - Present when status is PENDING or PAID

Note: Fields marked with ? are optional and may not be present in all responses.

Status Values

  • PENDING - Invoice created, waiting for payment
  • PAID - Invoice has been paid successfully
  • EXPIRED - Invoice has expired without payment
  • NOT_FOUND - Payment hash not found

Update User

PATCH /users/{id}

Update a user's external ID.

Path Parameters

id: Current user external ID (e.g., "user_123")

Request

{
  "updatedExternalId": "user_456", //optional
  "feePercent": 10, //optional
  "tipFeePercent": 10 //optional
}

Response

200 OK
{
  "id": "user-uuid-here",
  "externalId": "user_456",
  "feePercent": 10,
  "applicationId": "application-uuid-here",
  "walletId": "wallet-uuid-here",
  "kind": "STANDARD"
}

Delete User

DELETE /users/{id}

Delete a user and their wallet. Both go together, in one transaction — a wallet is never left behind.

A user with a financial history cannot be deleted. If their wallet still holds sats you get 409 USER_WALLET_NOT_EMPTY — withdraw first, or the funds would become unreachable. If they have policies, past sessions, transactions or deposit invoices you get 409 USER_NOT_EMPTY, with a count of each in details. This is deliberate: cascading would destroy accounting records. An unknown externalId returns 404 USER_NOT_FOUND.

For a right-to-erasure request, what you need is anonymisation — replace the externalId via PATCH /users/{id} — not deletion.

Path Parameters

id: User external ID (e.g., "user_123")

Response

200 OK
{
  "id": "user-uuid-here",
  "externalId": "user_123",
  "feePercent": 10,
  "applicationId": "application-uuid-here",
  "walletId": "wallet-uuid-here",
  "kind": "STANDARD"
}

Payment Policies

Define time-based payment rules.

Overview

What is a Payment Policy?

A Payment Policy says what one step of a session costs, how long a step lasts, and who receives the payment. It is attached to one or more resources: a session on a resource pays every policy of that resource at each step.

Beneficiary

externalUserId is the beneficiary — the user who is paid. It must be an existing user of your application, and it never changes afterwards: a PATCH with another externalUserId is refused with 409 PAYMENT_POLICY_BENEFICIARY_IMMUTABLE.

Owner

externalOwnerId (optional) is the owner — the user who manages the rule. It defaults to the beneficiary and may be another user of your application, so that one user manages the policy that pays another. PayEngine records it and returns it as ownerId; deciding who may edit a policy is up to your application. GET /payment-policies/owners/{id} lists the policies a user owns.

Payment Rate

Combine amount (whole sats), stepValue and stepUnitKeyName to define the rate. Example: 100 sats every 5 seconds = 20 sats per second.

Locked once attached

Once a policy is attached to a resource, its amount, stepValue and step unit are locked, for every resource that uses it: a change is refused with 409 PAYMENT_POLICY_ATTACHED. Its name and owner remain editable. Update Policy gives the procedure to change a price.

Use Cases

Perfect for streaming content, live sessions, pay-per-minute services, or any time-based monetization model.

Pro Tip

Create one policy per beneficiary and pricing tier. The same policy can be reused on several resources, as long as every resource keeps a single rhythm — see Split Payment.

Create Payment Policy

POST /payment-policies

Create a payment policy: the price of one step, the rhythm, the beneficiary and, optionally, the owner.

Parameters

externalUserId Required Beneficiary: external ID of the user who is paid. It never changes afterwards.
externalOwnerId Optional Owner: external ID of the user who manages the rule. Defaults to externalUserId.
name Required Your label for the policy.
amount Required Price of one step for this beneficiary, in whole sats (integer, at least 1).
stepValue Required Length of a step, as a number of step units (integer, 1 to 86400).
stepUnitKeyName Required Step unit, for example SECONDS — see Available Step Units.

Request

{
  "externalUserId": "user_123",
  "externalOwnerId": "user_456",
  "name": "policy_premium_video",
  "amount": 1,
  "stepValue": 5,
  "stepUnitKeyName": "SECONDS"
}

Units are asymmetric here. amount is sent in whole sats (integer, ≥ 1), but responses return amountMsat — a millisatoshi string (amountMsat = amount × 1000). A create-then-read round trip does not give you back the same field.

Response

201 Created
{
  "id": "policy-uuid-here",
  "userId": "user-uuid-here",
  "ownerId": "owner-uuid-here",
  "name": "policy_premium_video",
  "amountMsat": "1000",
  "stepValue": 5,
  "currency": "SATS",
  "stepUnitId": "step-unit-uuid-here",
  "createdAt": "2024-01-01T12:00:00Z"
}

404 USER_NOT_FOUND when the beneficiary or the owner does not exist in your application — details.field says which (externalUserId or externalOwnerId). 400 USER_RESERVED for a reserved identifier, 400 STEP_UNIT_NOT_FOUND for an unknown stepUnitKeyName, 400 VALIDATION_ERROR for a malformed body.

Get All Payment Policies

GET /payment-policies

Retrieve all payment policies in your application.

Response

200 OK
[
  {
    "id": "policy-uuid-here",
    "userId": "user-uuid-here",
    "ownerId": "user-uuid-here",
    "name": "policy_premium_video",
    "amountMsat": "1000",
    "stepValue": 5,
    "currency": "SATS",
    "stepUnitId": "step-unit-uuid-here",
    "createdAt": "2024-01-01T12:00:00Z",
    "stepUnit": {
      "id": "step-unit-uuid-here",
      "name": "SECONDS",
      "unitTypeId": "unit-type-uuid-here",
      "unitType": {
        "id": "unit-type-uuid-here",
        "name": "TIME"
      }
    }
  },
  {
    "id": "policy-uuid-2-here",
    "userId": "user-uuid-2-here",
    "ownerId": "user-uuid-here",
    "name": "policy_audio_stream",
    "amountMsat": "2000",
    "stepValue": 10,
    "currency": "SATS",
    "stepUnitId": "step-unit-uuid-here",
    "createdAt": "2024-01-01T13:00:00Z",
    "stepUnit": {
      "id": "step-unit-uuid-here",
      "name": "SECONDS",
      "unitTypeId": "unit-type-uuid-here",
      "unitType": {
        "id": "unit-type-uuid-here",
        "name": "TIME"
      }
    }
  }
]

Get Payment Policy

GET /payment-policies/{id}

Get a specific payment policy by its ID, with the external IDs of its beneficiary and its owner.

Path Parameters

id: Payment policy UUID

Response

200 OK
{
  "id": "policy-uuid-here",
  "userId": "user-uuid-here",
  "ownerId": "owner-uuid-here",
  "name": "policy_premium_video",
  "amountMsat": "1000",
  "stepValue": 5,
  "currency": "SATS",
  "stepUnitId": "step-unit-uuid-here",
  "createdAt": "2024-01-01T12:00:00Z",
  "stepUnit": {
    "id": "step-unit-uuid-here",
    "name": "SECONDS",
    "unitTypeId": "unit-type-uuid-here",
    "unitType": {
      "id": "unit-type-uuid-here",
      "name": "TIME"
    }
  },
  "user": {
    "externalId": "user_123"
  },
  "owner": {
    "externalId": "user_456"
  }
}

A policy that does not exist in your application answers 200 with the body null. An id that is not a UUID answers 400 VALIDATION_ERROR.

Get Policies for User

GET /payment-policies/users/{id}

Every payment policy for which the user is the beneficiary — the one who is paid. _count.resources is the number of resources the policy is attached to.

Path Parameters

id: User external ID (e.g., "user_123")

Response

200 OK
[
  {
    "id": "policy-uuid-here",
    "userId": "user-uuid-here",
    "ownerId": "owner-uuid-here",
    "name": "policy_premium_video",
    "amountMsat": "1000",
    "stepValue": 5,
    "currency": "SATS",
    "stepUnitId": "step-unit-uuid-here",
    "createdAt": "2024-01-01T12:00:00Z",
    "stepUnit": {
      "id": "step-unit-uuid-here",
      "name": "SECONDS",
      "unitTypeId": "unit-type-uuid-here"
    },
    "_count": {
      "resources": 2
    }
  }
]

Get Policies for Owner

GET /payment-policies/owners/{id}

Every payment policy the user owns — the ones they manage, whoever their beneficiary is. Same response shape as Get Policies for User. A policy created without externalOwnerId is owned by its beneficiary, so it appears in both lists.

Path Parameters

id: Owner external ID (e.g., "user_456")

Response

200 OK
[
  {
    "id": "policy-uuid-here",
    "userId": "user-uuid-here",
    "ownerId": "owner-uuid-here",
    "name": "policy_premium_video",
    "amountMsat": "1000",
    "stepValue": 5,
    "currency": "SATS",
    "stepUnitId": "step-unit-uuid-here",
    "createdAt": "2024-01-01T12:00:00Z",
    "stepUnit": {
      "id": "step-unit-uuid-here",
      "name": "SECONDS",
      "unitTypeId": "unit-type-uuid-here"
    },
    "_count": {
      "resources": 2
    }
  }
]

Get Policy for Resource (deprecated)

GET /payment-policies/resources/{id}

Deprecated. A resource can carry several policies: read its group with GET /resources/{id}/policies, which also gives the total price.

Returns the policy of a resource that has exactly one. A resource without any policy (or unknown) answers 200 with the body null. From two policies on, the route answers 409 RESOURCE_HAS_MULTIPLE_POLICIES with details.policyCount: one policy alone is not the price of the resource.

Path Parameters

id: Resource external ID (e.g., "res-123")

Response

200 OK
{
  "id": "policy-uuid-here",
  "userId": "user-uuid-here",
  "ownerId": "user-uuid-here",
  "name": "policy_premium_video",
  "amountMsat": "1000",
  "stepValue": 5,
  "currency": "SATS",
  "stepUnitId": "step-unit-uuid-here",
  "createdAt": "2024-01-01T12:00:00Z",
  "stepUnit": {
    "id": "step-unit-uuid-here",
    "name": "SECONDS",
    "unitTypeId": "unit-type-uuid-here"
  }
}

Update Payment Policy

PATCH /payment-policies/{id}

Partial update: a field that is absent is left untouched, and null is refused (400). name and externalOwnerId can always be changed; the price and the rhythm only while the policy is attached to no resource.

Path Parameters

id: Payment policy UUID

Request

{
  "name": "policy_updated_name",
  "amount": 10,
  "stepValue": 1,
  "stepUnitKeyName": "SECONDS",
  "externalOwnerId": "user_456"
}
  • Attached policy. Changing amount, stepValue or stepUnitKeyName of a policy attached to at least one resource is refused with 409 PAYMENT_POLICY_ATTACHED; details gives resourceCount and up to 50 resourceIds. Sending the current value is accepted.
  • Beneficiary. externalUserId is accepted only when it equals the current beneficiary; any other value is refused with 409 PAYMENT_POLICY_BENEFICIARY_IMMUTABLE (details.current).
  • id in the body. Tolerated, because older SDKs send it; it must equal the id of the URL, otherwise 400 VALIDATION_ERROR.
  • Other errors. 404 PAYMENT_POLICY_NOT_FOUND when the policy does not exist in your application; 400 STEP_UNIT_NOT_FOUND for an unknown step unit; 404 USER_NOT_FOUND or 400 USER_RESERVED for the new owner.

Changing the price of an attached policy

The amount and the rhythm of an attached policy price every resource it is attached to, so they are locked. Two ways to change them:

  1. Replace the group (recommended): create a new policy at the new price, then send PATCH /resources/{id} with policyIds listing the new policy in place of the old one. The swap is atomic, resource by resource.
  2. Detach, update, re-attach: DELETE /resources/{id}/policies/{policyId} on every resource listed in details.resourceIds, PATCH the policy, then PUT /resources/{id}/policies/{policyId} again — the policy comes back last in the order. A resource always keeps at least one policy: when the policy is the only one, use the first way.

Sessions already open are never affected: they keep the split recorded when they opened.

Response

200 OK
{
  "id": "policy-uuid-here",
  "userId": "user-uuid-here",
  "ownerId": "owner-uuid-here",
  "name": "policy_updated_name",
  "amountMsat": "10000",
  "stepValue": 1,
  "currency": "SATS",
  "stepUnitId": "step-unit-uuid-here",
  "createdAt": "2024-01-01T12:00:00Z"
}
409 Conflict
Price change on an attached policy
{
  "code": "PAYMENT_POLICY_ATTACHED",
  "message": "The amount and rhythm of an attached policy cannot change: detach it, update it, then attach it again (or replace the group)",
  "details": {
    "resourceCount": 2,
    "resourceIds": ["res-123", "res-456"]
  }
}

Delete Payment Policy

DELETE /payment-policies/{id}

Delete a payment policy. Refused while it is attached to a resource — 409 PAYMENT_POLICY_USED_BY_RESOURCES, with details.resourceCount — and once sessions or debit events reference it — 409 PAYMENT_POLICY_IN_USE, with details.sessions and details.debitEvents: its history is kept. 404 PAYMENT_POLICY_NOT_FOUND when it does not exist in your application.

Path Parameters

id: Payment policy UUID

Response

200 OK
{
  "id": "policy-uuid-here",
  "userId": "user-uuid-here",
  "ownerId": "user-uuid-here",
  "name": "policy_premium_video",
  "amountMsat": "1000",
  "stepValue": 5,
  "currency": "SATS",
  "stepUnitId": "step-unit-uuid-here",
  "createdAt": "2024-01-01T12:00:00Z"
}

Resources Management

Manage billable resources and content in your application.

Overview

What is a Resource?

A Resource represents any billable content or service in your application — a video, an audio file, an article, a live stream, a course or an API endpoint. A resource carries one or more payment policies; a session always opens on a resource and pays all of them.

External Resource ID

The externalResourceId is your unique identifier for this resource in your system. This allows you to map PayEngine resources to content in your database.

Payment Policies

Each resource carries one to ten payment policies — one per beneficiary. The price of one step is the sum of their amounts: see Split Payment.

Examples

Video streaming, audio playback, article reading, API usage, live streaming, or any time-based service.

🚀 Best Practice: Naming Convention

Include the resource type in your externalResourceId for better organization:

  • res-12345 - for any content
  • video_67890 - for videos
  • stream_abc123 - for live streams
  • article_xyz789 - for articles

Automatic Unlock

A resource can carry an unlockThresholdMsat — a millisatoshi amount as a string, strictly positive integer. Once a user has spent that amount (gross: the full price of each step, every share and fee included) consuming the resource, they own it permanently: new WebSocket sessions for that resource are refused with close code 4011, and a session in progress that crosses the threshold is closed with 4011 and an ENDED webhook carrying reason: "UNLOCKED".

The right is permanent: raising, changing or removing the threshold later never revokes it. Spending is only counted from the M19 deployment onwards — spending that happened before that release does not count towards the threshold.

Split Payment

A resource can carry several payment policies, one per beneficiary. At each step the payer pays the sum of their amounts, and each beneficiary receives its own share, minus its own commission. Example: three policies at 5, 2 and 1 sats every 30 seconds — a step costs 8 sats, paid out 5 / 2 / 1.

Rules of a group

  • Same rhythm. Every policy of a resource has the same stepValue, step unit and currency — otherwise 409 RESOURCE_POLICY_RHYTHM_MISMATCH.
  • Ten policies at most. The platform may apply a lower limit, returned as pricing.maxPolicies; beyond it, 409 RESOURCE_POLICY_LIMIT_REACHED.
  • One beneficiary per resource. Two policies of the same beneficiary cannot share a resource — 409 RESOURCE_BENEFICIARY_ALREADY_PRESENT.
  • At least one policy. A resource never has an empty group: detaching its last policy is refused (409 RESOURCE_LAST_POLICY) — delete the resource, or replace its group.
  • 1 sat minimum per share. Each policy costs at least 1 sat per step, so three beneficiaries cost at least 3 sats per step. For a lower price, lengthen the step.
  • Payable beneficiaries only. A policy whose beneficiary has no wallet cannot be attached (409 POLICY_BENEFICIARY_WALLET_MISSING), and a reserved user is never a beneficiary (400 USER_RESERVED).
  • Order. Policies keep the order in which they were given, then attached. The first one is the policyId kept for compatibility in resource responses and in webhooks.

The split is recorded when the session opens

When a session opens, PayEngine records its split — policies, amounts, receiving wallets, commission rates. The session is billed on that record until it ends: attaching, detaching or replacing policies, or changing a commission rate, only affects the sessions opened afterwards.

Each step is settled in a single transaction: every share is paid, or none is. When the balance does not cover the full price of a step, nothing moves and the session closes with 1008.

Free for a beneficiary

A session is free when the payer is one of the beneficiaries of the resource: no step is billed and nobody is paid, whatever the other shares. For that payer, pricing.payerPricePerStepMsat is "0" — pass payerExternalId to read it.

Lost funds

If the wallet of a beneficiary is unavailable — already at opening, or gone during the session — its share is credited to __payengine_lost_funds__, the reserved user of your application, instead of being lost. The payer still pays the full price, the commission is taken as usual, and the debit event of that policy reports the amount in lostFundsMsat. These funds are not redistributed automatically: contact PayEngine support.

List Resources

GET /resources

Every resource of your application, with its group of policies and the price of a step, sorted by externalResourceId.

Response

200 OK
[
  {
    "externalResourceId": "res-456",
    "applicationId": "application-uuid-here",
    "policyId": "policy-d-uuid",
    "policyIds": ["policy-d-uuid"],
    "unlockThresholdMsat": null,
    "policy": {
      "id": "policy-d-uuid",
      "userId": "user-d-uuid",
      "ownerId": "user-d-uuid",
      "name": "res-456 price",
      "amountMsat": "2000",
      "stepValue": 10,
      "currency": "SATS",
      "stepUnitId": "step-unit-uuid-here",
      "createdAt": "2026-10-20T09:00:00.000Z",
      "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS", "unitTypeId": "unit-type-uuid-here" }
    },
    "policies": [
      {
        "id": "policy-d-uuid",
        "userId": "user-d-uuid",
        "ownerId": "user-d-uuid",
        "name": "res-456 price",
        "amountMsat": "2000",
        "stepValue": 10,
        "currency": "SATS",
        "stepUnitId": "step-unit-uuid-here",
        "createdAt": "2026-10-20T09:00:00.000Z",
        "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS", "unitTypeId": "unit-type-uuid-here" },
        "user": { "externalId": "user_d" },
        "owner": { "externalId": "user_d" },
        "position": 0,
        "attachedAt": "2026-10-20T09:01:00.000Z"
      }
    ],
    "pricing": {
      "pricePerStepMsat": "2000",
      "stepValue": 10,
      "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS" },
      "currency": "SATS",
      "policyCount": 1,
      "maxPolicies": 10
    }
  }
]

policy is deprecated: it holds the policy when the resource has exactly one, and is null from two policies on — read policies and pricing. policyId is the first policy of the group. Remains accessible while the application is SUSPENDED (read).

Get Policies for Resources

POST /resources/policies

Read several resources at once by their external IDs — same items as List Resources; unknown IDs are simply absent from the answer. Pass payerExternalId to add, in pricing, the price that payer would pay: "0" when they are one of the beneficiaries. A read despite the verb: it remains accessible while the application is SUSPENDED.

Request

{
  "resourceIds": ["res-123", "res-456"],
  "payerExternalId": "user_9"
}

Response

201 Created
[
  {
    "externalResourceId": "res-123",
    "applicationId": "application-uuid-here",
    "policyId": "policy-a-uuid",
    "policyIds": ["policy-a-uuid", "policy-b-uuid", "policy-c-uuid"],
    "unlockThresholdMsat": "500000",
    "policy": null,
    "policies": [
      { "id": "policy-a-uuid", "amountMsat": "5000", "user": { "externalId": "user_a" }, "position": 0, "...": "same fields as in List Resources" },
      { "id": "policy-b-uuid", "amountMsat": "2000", "user": { "externalId": "user_b" }, "position": 1, "...": "same fields as in List Resources" },
      { "id": "policy-c-uuid", "amountMsat": "1000", "user": { "externalId": "user_c" }, "position": 2, "...": "same fields as in List Resources" }
    ],
    "pricing": {
      "pricePerStepMsat": "8000",
      "stepValue": 30,
      "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS" },
      "currency": "SATS",
      "policyCount": 3,
      "maxPolicies": 10,
      "payerExternalId": "user_9",
      "payerIsBeneficiary": false,
      "payerPricePerStepMsat": "8000"
    }
  },
  {
    "externalResourceId": "res-456",
    "...": "one policy: policy is filled, see List Resources",
    "pricing": {
      "pricePerStepMsat": "2000",
      "stepValue": 10,
      "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS" },
      "currency": "SATS",
      "policyCount": 1,
      "maxPolicies": 10,
      "payerExternalId": "user_9",
      "payerIsBeneficiary": false,
      "payerPricePerStepMsat": "2000"
    }
  }
]

policy is deprecated, and null from two policies on. One policy is only one share: a client that reads the price from policy would show 5 sats and bill 8. Read the price from pricing.pricePerStepMsat, and the shares from policies.

Link Resource to Policies

POST /resources

Create a resource with its group of payment policies: policyId for a single policy, or policyIds — 1 to 10 distinct policy UUIDs, in split order — for several. Send exactly one of the two. The whole group is checked against the rules of a group before anything is written; the first policy becomes policyId.

unlockThresholdMsat is optional: a millisatoshi amount as a string — positive integer, 18 digits maximum (e.g. "500000" = 500 sats). When set, the resource unlocks automatically once a user has spent that amount on it. The field is returned in the response as a string, or null when no threshold is set.

Request

{
  "externalResourceId": "res-123",
  "policyIds": ["policy-a-uuid", "policy-b-uuid", "policy-c-uuid"],
  "unlockThresholdMsat": "500000"
}

With a single policy, the historical form still works:

{
  "externalResourceId": "res-456",
  "policyId": "policy-d-uuid"
}

Response

201 Created
{
  "externalResourceId": "res-123",
  "applicationId": "application-uuid-here",
  "policyId": "policy-a-uuid",
  "policyIds": ["policy-a-uuid", "policy-b-uuid", "policy-c-uuid"],
  "unlockThresholdMsat": "500000"
}

400 RESOURCE_ALREADY_EXIST when the resource already exists; 404 PAYMENT_POLICY_NOT_FOUND (details.policyId) for a policy that does not exist in your application; 400 USER_RESERVED or a 409 for a group rule (RESOURCE_POLICY_RHYTHM_MISMATCH, RESOURCE_BENEFICIARY_ALREADY_PRESENT, RESOURCE_POLICY_LIMIT_REACHED, POLICY_BENEFICIARY_WALLET_MISSING); 400 VALIDATION_ERROR when both or neither of policyId and policyIds are sent, or for an empty, duplicated or oversized list.

Update Resource

PATCH /resources/{id}

Partial update of a resource: send policyIds or policyId, and/or unlockThresholdMsat. A field that is absent is left untouched.

  • policyIds replaces the whole group, atomically: the rules of a group apply to the new set, and when they refuse it the previous group stays intact.
  • policyId replaces the policy of a resource that has at most one. From two policies on it is refused with 409 RESOURCE_HAS_MULTIPLE_POLICIES (details.policyCount), so that an older client never overwrites a group.
  • Sending both policyId and policyIds is refused (400 VALIDATION_ERROR). An empty body answers 404 PAYMENT_POLICY_NOT_FOUND.
  • When the resource does not exist, a request carrying policyId creates it (behavior kept for compatibility). policyIds never creates: it answers 404 RESOURCE_NOT_FOUND and nothing is written, so a group replacement that arrives after the resource was deleted never brings it back. To create a resource with a group, use POST /resources. With unlockThresholdMsat alone, the answer is also 404 RESOURCE_NOT_FOUND.

Path Parameters

id: Resource external ID (e.g., "res-123")

Request

{
  "policyIds": ["policy-a-uuid", "policy-e-uuid", "policy-c-uuid"],
  "unlockThresholdMsat": "500000"
}

Sending "unlockThresholdMsat": null removes the threshold — the resource stops being unlockable, but rights already acquired are kept.

Response

200 OK
{
  "externalResourceId": "res-123",
  "applicationId": "application-uuid-here",
  "policyId": "policy-a-uuid",
  "policyIds": ["policy-a-uuid", "policy-e-uuid", "policy-c-uuid"],
  "unlockThresholdMsat": "500000"
}

Get Resource Policies

GET /resources/{id}/policies

The group of a resource: its policies in split order — each with its beneficiary, owner and position — and the price of a step in pricing.

Path Parameters

id: Resource external ID (e.g., "res-123")

Query Parameters

payerExternalId Optional A payer's external ID: adds payerExternalId, payerIsBeneficiary and payerPricePerStepMsat to pricing — "0" when that payer is one of the beneficiaries. An unknown payer is not a beneficiary.

Response

200 OK
{
  "externalResourceId": "res-123",
  "applicationId": "application-uuid-here",
  "policyId": "policy-a-uuid",
  "policyIds": ["policy-a-uuid", "policy-b-uuid", "policy-c-uuid"],
  "unlockThresholdMsat": "500000",
  "pricing": {
    "pricePerStepMsat": "8000",
    "stepValue": 30,
    "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS" },
    "currency": "SATS",
    "policyCount": 3,
    "maxPolicies": 10,
    "payerExternalId": "user_b",
    "payerIsBeneficiary": true,
    "payerPricePerStepMsat": "0"
  },
  "policies": [
    {
      "id": "policy-a-uuid",
      "userId": "user-a-uuid",
      "ownerId": "user-a-uuid",
      "name": "res-123 main share",
      "amountMsat": "5000",
      "stepValue": 30,
      "currency": "SATS",
      "stepUnitId": "step-unit-uuid-here",
      "createdAt": "2026-10-20T10:00:00.000Z",
      "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS", "unitTypeId": "unit-type-uuid-here" },
      "user": { "externalId": "user_a" },
      "owner": { "externalId": "user_a" },
      "position": 0,
      "attachedAt": "2026-10-20T10:05:00.000Z"
    },
    {
      "id": "policy-b-uuid",
      "userId": "user-b-uuid",
      "ownerId": "user-a-uuid",
      "name": "res-123 second share",
      "amountMsat": "2000",
      "stepValue": 30,
      "currency": "SATS",
      "stepUnitId": "step-unit-uuid-here",
      "createdAt": "2026-10-20T10:01:00.000Z",
      "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS", "unitTypeId": "unit-type-uuid-here" },
      "user": { "externalId": "user_b" },
      "owner": { "externalId": "user_a" },
      "position": 1,
      "attachedAt": "2026-10-20T10:05:00.000Z"
    },
    {
      "id": "policy-c-uuid",
      "userId": "user-c-uuid",
      "ownerId": "user-a-uuid",
      "name": "res-123 third share",
      "amountMsat": "1000",
      "stepValue": 30,
      "currency": "SATS",
      "stepUnitId": "step-unit-uuid-here",
      "createdAt": "2026-10-20T10:02:00.000Z",
      "stepUnit": { "id": "step-unit-uuid-here", "name": "SECONDS", "unitTypeId": "unit-type-uuid-here" },
      "user": { "externalId": "user_c" },
      "owner": { "externalId": "user_a" },
      "position": 2,
      "attachedAt": "2026-10-20T10:05:00.000Z"
    }
  ]
}
pricing.pricePerStepMsatPrice of one step: the sum of every share, in msat. This is what a payer is charged per step — the only source for a displayed price.
pricing.maxPoliciesHow many policies this resource may carry at most (10, or less if the platform applies a lower limit).
pricing.payer*Only when payerExternalId is sent. payerPricePerStepMsat is "0" when the payer is one of the beneficiaries: their session is free.
policies[].positionOrder of the policy in the group (gaps are possible). The lowest one gives policyId.

404 RESOURCE_NOT_FOUND when the resource does not exist in your application. Remains accessible while the application is SUSPENDED (read). Edge case: a resource named unlocks cannot be read by this route, which GET /resources/unlocks/{payerExternalId} shadows — read it with POST /resources/policies.

Attach Policy

PUT /resources/{id}/policies/{policyId}

Add a policy to the group of an existing resource. The call is idempotent: attaching a policy that is already in the group changes nothing and answers 200. The new policy comes last in the order. No body; the response is the group after the change, in the same shape as Get Resource Policies (without the payer* fields). Sessions already open keep their split.

Path Parameters

id: Resource external ID (e.g., "res-123")
policyId: Payment policy UUID

404 RESOURCE_NOT_FOUND — a resource is never created by this route; 404 PAYMENT_POLICY_NOT_FOUND; 400 USER_RESERVED; 409 POLICY_BENEFICIARY_WALLET_MISSING; 409 RESOURCE_POLICY_LIMIT_REACHED (details.max, details.current); 409 RESOURCE_POLICY_RHYTHM_MISMATCH (details.expected, details.got, details.policyId); 409 RESOURCE_BENEFICIARY_ALREADY_PRESENT (details.userExternalId, details.policyId); 400 VALIDATION_ERROR when policyId is not a UUID.

409 Conflict
Policy with another rhythm
{
  "code": "RESOURCE_POLICY_RHYTHM_MISMATCH",
  "message": "Every policy of a resource must share the same step and currency",
  "details": {
    "expected": { "stepValue": 30, "stepUnit": "SECONDS", "currency": "SATS" },
    "got": { "stepValue": 60, "stepUnit": "SECONDS", "currency": "SATS" },
    "policyId": "policy-f-uuid"
  }
}

Detach Policy

DELETE /resources/{id}/policies/{policyId}

Remove a policy from the group of a resource; the policy itself is kept. Detaching the first policy promotes the next one, so the policyId of the resource changes. The response is the group after the change, in the same shape as Get Resource Policies. Sessions already open keep their split.

Path Parameters

id: Resource external ID (e.g., "res-123")
policyId: Payment policy UUID

404 RESOURCE_NOT_FOUND; 404 RESOURCE_POLICY_NOT_ATTACHED (details.policyId) when the policy is not in the group; 409 RESOURCE_LAST_POLICY for the last policy of the group — delete the resource, or replace its group with PATCH /resources/{id}.

Get Unlock Status

GET /resources/{externalResourceId}/unlock-status/{payerExternalId}

Read whether a user has unlocked a resource, and their progress towards the threshold. If the cumulative spend has already crossed the threshold without the right being materialized (edge case), the read materializes it in passing — best-effort, the response does not depend on it.

Path Parameters

externalResourceId: Resource external ID (e.g., "res-123")
payerExternalId: Paying user external ID (e.g., "user-42")

Response

200 OK
{
  "externalResourceId": "res-123",
  "payerExternalId": "user-42",
  "unlockable": true,
  "unlockThresholdMsat": "500000",
  "spentMsat": "512000",
  "unlocked": true,
  "unlockedAt": "2026-08-16T12:00:00.000Z"
}

unlocked can be true before unlockedAt exists — the right has not been materialized yet: treat unlocked as the truth. Requires the x-api-key header and is scoped to your application, like the other resource endpoints; it remains accessible while the application is SUSPENDED (read).

Returns 404 RESOURCE_NOT_FOUND when the resource does not exist in your application.

List Unlocked Resources

GET /resources/unlocks/{payerExternalId}

Every resource a user has unlocked, sorted by unlockedAt descending.

Path Parameters

payerExternalId: Paying user external ID (e.g., "user-42")

Response

200 OK
[
  {
    "externalResourceId": "res-123",
    "payerExternalId": "user-42",
    "thresholdMsat": "500000",
    "spentMsat": "512000",
    "unlockedAt": "2026-08-16T12:00:00.000Z"
  }
]

thresholdMsat and spentMsat are the values recorded at the moment of the unlock. Requires the x-api-key header and is scoped to your application; it remains accessible while the application is SUSPENDED (read).

Delete Resource

DELETE /resources/{id}

Remove a resource from monetization. Its links to policies are deleted with it; the policies themselves are kept. Sessions already open on the resource continue on their recorded split until they end. The response lists the policies the resource had, in policyIds. 404 RESOURCE_NOT_FOUND when the resource does not exist in your application.

Path Parameters

id: Resource external ID (e.g., "res-123")

Response

200 OK
{
  "externalResourceId": "res-123",
  "applicationId": "application-uuid-here",
  "policyId": "policy-a-uuid",
  "policyIds": ["policy-a-uuid", "policy-b-uuid", "policy-c-uuid"],
  "unlockThresholdMsat": "500000"
}

Units

Understanding the time units used in payment policies.

Overview

Understanding Unit Types & Step Units

Payment policies use a two-level system to define time-based billing intervals. This system provides flexibility and standardization for various monetization models.

Unit Type

The Unit Type represents the category of measurement used for billing intervals. Currently, TIME is the primary unit type, focusing on temporal-based payments.

Step Unit

The Step Unit defines the specific increment within a Unit Type. For the TIME unit type, step units include SECONDS, MINUTES, and HOURS.

Available Step Units

Current step units available for the TIME unit type:

SECONDS

Perfect for real-time streaming content like videos, music, or live streams.

stepUnit: "SECONDS"

MINUTES

Ideal for services billed per minute, like voice calls or consultations.

stepUnit: "MINUTES"

HOURS

Best for hourly services, professional consultations, or long-term access.

stepUnit: "HOURS"

Get All Unit Types

GET /units

Retrieve all available unit types with their associated step units.

Response

200 OK
[
  {
    "id": "unit-type-uuid-here",
    "name": "TIME",
    "units": [
      {
        "id": "step-unit-uuid-1-here",
        "name": "SECONDS"
      },
      {
        "id": "step-unit-uuid-2-here", 
        "name": "MINUTES"
      },
      {
        "id": "step-unit-uuid-3-here",
        "name": "HOURS"
      }
    ]
  }
]

Get Step Units by Unit Type

GET /units/{name}/steps

Get all step units for a specific unit type.

Path Parameters

name: Unit type name (e.g., "TIME")

Response

200 OK
[
  {
    "id": "step-unit-uuid-1-here",
    "name": "SECONDS"
  },
  {
    "id": "step-unit-uuid-2-here",
    "name": "MINUTES"
  },
  {
    "id": "step-unit-uuid-3-here",
    "name": "HOURS"
  }
]

Step Unit Data Structure

When retrieving payment policies, step units are returned as nested objects with their associated unit type information:

Example Step Unit Object in Policy

{
  "stepUnit": {
    "id": "step-unit-uuid-here",
    "name": "SECONDS",
    "unitTypeId": "unit-type-uuid-here",
    "unitType": {
      "id": "unit-type-uuid-here",
      "name": "TIME"
    }
  }
}
💡 Integration Tip

When creating payment policies, you only need to specify the stepUnitKeyName string (e.g., "SECONDS"). The API will automatically resolve the full structure with IDs and relationships.

Webhook Events

Receive real-time notifications.

⚠️ Breaking change — signature header

The signature is now sent as x-payengine-signature holding a bare hex HMAC-SHA256 — the former X-Signature header and the hmac_sha256= prefix are gone.

  • The HMAC is computed over the body exactly as sent. Verify it against the raw body, never against a re-serialized object — key ordering would differ and every signature would be rejected.
  • Compare in constant time (crypto.timingSafeEqual).

Event Types

STARTED

A new payment session has started

PAUSED

Session has been paused

RESUMED

Session has resumed after a pause

ENDED

Session has ended — carries a reason

Fields present on every emission

schemaVersionnumber (2) — lets two payload formats coexist.
eventIduuid, unique per emission. Deduplicate on it — this is mandatory. Delivery is retried, so receiving the same event twice is normal rather than exceptional.
emittedAtISO-8601 send time. Distinct from timestamp (business time). On a retry it stays the time of the first attempt.
eventSTARTED | PAUSED | RESUMED | ENDED
sessionId, payerId, payerExternalId, policyId, resourceId, timestampSession context — who pays for which resource. policyId is the first policy of the session's split.
policyIdsarray of uuid — every policy the session pays, in split order; policyIds[0] equals policyId. Added without a schema change (schemaVersion stays 2): an event emitted before split payment and delivered late does not carry it.

Event-specific fields

PAUSEDremaining (seconds left in the current step)
ENDEDreason, plus the session summary: sessionPaidSecTotal and sessionPaidMsatTotal (msat, as a string)

Webhooks describe the session lifecycle, not the billing cadence

STARTED opens the right to serve, ENDED closes it. Nothing is emitted in between while billing runs — that traffic would be proportional to the duration of sessions rather than to their number, and it would duplicate what the WebSocket already delivers to the client. A session refused at opening never started: it sends no webhook at all.

Two consequences to design for. Failed deliveries are retried, so duplicates are expected — deduplicate on eventId. And because retries are spaced out, ordering is not guaranteed: a retried ENDED can arrive after the STARTED of the next session. Rely on sessionId and timestamp, never on arrival order.

The ENDED summary is best-effort: the counters live in memory, so if the server restarts mid-session the session resumes but its counters restart from that point, and the summary understates. sessionPaidMsatTotal is the total paid by the payer, every share included. Never bill on it — the debit events API is the accounting source of truth.

reason values (ENDED)

WS close code reason Meaning
1000DISCONNECTEDNormal end / client disconnect.
1001SHUTDOWNServer shutting down (a deployment, not a fault). Reconnect with a fresh token.
1008NO_FUNDInsufficient balance.
4008NO_PINGHeartbeat expired.
4010BACKPRESSUREClient too slow (server memory protection).
4011UNLOCKEDCumulative spend crossed the unlock threshold — the resource is now owned. Not an error: mark it owned and stop opening payment sessions for it.
otherUNEXPECTED_ERROR

Refusals at opening — 4001 to 4007, 4009, 4012 to 4014, 4403, and 1008 or 4011 when the session is refused before it starts — have no reason: no session started, so neither STARTED nor ENDED is sent. See the close codes.

Payload example — STARTED

{
  "schemaVersion": 2,
  "eventId": "6f1a2b3c-4d5e-4f60-8a71-9b2c3d4e5f60",
  "emittedAt": "2026-08-09T10:00:00.004Z",
  "event": "STARTED",
  "sessionId": "session-uuid",
  "payerId": "user_123",
  "payerExternalId": "user_123",
  "policyId": "policy-a-uuid",
  "policyIds": ["policy-a-uuid", "policy-b-uuid", "policy-c-uuid"],
  "resourceId": "res-123",
  "timestamp": "2026-08-09T10:00:00.003Z"
}

Payload example — ENDED (carries the summary)

{
  "schemaVersion": 2,
  "eventId": "8c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
  "emittedAt": "2026-08-09T10:03:20.004Z",
  "event": "ENDED",
  "sessionId": "session-uuid",
  "payerId": "user_123",
  "payerExternalId": "user_123",
  "policyId": "policy-a-uuid",
  "policyIds": ["policy-a-uuid", "policy-b-uuid", "policy-c-uuid"],
  "resourceId": "res-123",
  "timestamp": "2026-08-09T10:03:20.003Z",
  "reason": "DISCONNECTED",
  "sessionPaidSecTotal": 210,
  "sessionPaidMsatTotal": "56000"
}

Analytics does not depend on webhooks. Incremental Sync is self-sufficient and catches up on anything a lost webhook never delivered. Webhooks remain essential to the business flow — knowing when to open, and when to close, the right to use the resource.

Debit Events

Track and retrieve payment debit events for users and resources.

Overview

What is a Debit Event?

A Debit Event records what one payment policy received during one session: the amount paid for its share, the commission taken, the number of steps billed, and the completion status.

One Line per Policy and per Session

A debit event aggregates a whole session for one policy, not a single step: 20 steps × 2 sats produce one event of 40 000 msat. When the resource has several policies, the session produces one event per policy, each carrying only its own share: 20 steps on a resource priced 5 + 2 + 1 sats produce three events of 100 000, 40 000 and 20 000 msat. amountMsat is the share paid, fees included.

Policy Association

Each event carries its policyId and its shareIndex — the rank of the policy in the session's split. What a beneficiary received is the sum of the events of its policies; what a session cost is the sum of its events, or its breakdown.

Date Range Filtering

Query debit events within specific date ranges based on completion timestamps for detailed analytics.

Use Cases

Perfect for generating reports, tracking user spending, auditing transactions, and analyzing resource usage patterns.

⚠️ Breaking changes — SDK 2.0.0

  • Amounts are now millisatoshi strings. amount / feesAmount (number, sats) are replaced by amountMsat / feesMsat (string, msat). The canonical unit is the msat, held as a BigInt server-side — not representable in JSON, and beyond Number.MAX_SAFE_INTEGER for large balances. Use BigInt(value) for arithmetic, never floats.
  • status no longer has FAILED. Only PENDING and SUCCESS remain. A tick without funds moves no msat, so there is nothing to record.
  • Webhook signature header changed to x-payengine-signature, carrying a bare hex HMAC-SHA256 (no hmac_sha256= prefix). See Webhook Events.
  • Date fields are ISO-8601 strings, not Date objects. They always were at runtime — the SDK type was wrong and now matches reality.

Debit Event Model

Fields exposed on every debit event. All monetary amounts are millisatoshi strings.

Field Type Meaning
iduuidUpsert key on the consumer side.
sessionIdstringOriginating session. Survives session purge.
policyIduuid | nullThe policy this line pays: one line per policy and per session. null only on historical rows.
shareIndexintRank of the policy in the session's split, 0 for the first. 0 on every row recorded before split payment.
amountMsatstring (msat)Share paid for this policy, fees included. net = amountMsat - feesMsat.
feesMsatstring (msat)Commission taken by the application on this share, at the beneficiary's rate. It is computed on each settlement: read it, never recompute it from the price of a step.
lostFundsMsatstring (msat)Part of the net credited to the reserved lost-funds user instead of the beneficiary (see Split Payment). Usually "0". Net actually received by the beneficiary: amountMsat - feesMsat - lostFundsMsat.
statusPENDING | SUCCESSMutable. See lifecycle below.
createdAtISO-8601Instant of the first settled step — not the start of the session.
updatedAtISO-8601Last mutation. Key to incremental sync.
completedAtISO-8601 | nullInstant of PENDING → SUCCESS (session close). Not a Lightning confirmation.
startedAtISO-8601 | nullStart of the session (WebSocket open). Use for hourly series.
payerExternalIdstring | nullPaying-user id on the application side.
applicationIduuid | nullOwning application — the scoping column of the listing.
unitsChargedintSteps actually billed — the same on every line of a session. Paid duration = unitsCharged × stepValue, read on one line (shareIndex 0): never add it up across the lines of a session.
resourceIdstring | nullResource billed by the session.
currencySATS | USDTCurrency of the policy.

Note on feePercent

The fee rate is deliberately not exposed: the application sets its own commission and already knows its rate. feesMsat — the amount actually taken — is enough to compute the net.

Status lifecycle

  1. On the first settled step, one row per policy of the session is created as PENDING in the same transaction as the wallet-to-wallet transfers. A PENDING row therefore means the msat have already moved.
  2. On each following step, amountMsat, feesMsat, lostFundsMsat and unitsCharged are incremented on those same rows (and updatedAt advances). Every row of a session moves together: a step pays every share, or none.
  3. On session close, the rows flip to SUCCESS with completedAt set. This transition is not reversible.

Rare case. A step settled just after the close can briefly recreate a PENDING row for a policy that already has its SUCCESS row. The finalization of the session then merges it into that SUCCESS row — whose id is kept — and deletes the PENDING row, which therefore no longer appears in the listing. If you had imported it: for the same sessionId and policyId, a local PENDING row whose updatedAt is earlier than a SUCCESS row received afterwards has been merged — remove it.

Always upsert on id — never insert-if-absent. Amounts and status are mutable while PENDING. And do not filter on SUCCESS: a session in progress is real revenue.

Incremental Sync

GET /debit-events

List every debit event that changed since your last import, with a stable cursor. Scoped to the application owning the API key.

Query Parameters

updatedSince optional ISO-8601. Lower bound on updatedAt, inclusive (>=).
limit optional Page size. Default 500, server hard cap 1000. Out of range ⇒ 400.
cursor optional Opaque cursor returned by the previous page. Send it back verbatim; do not build one.

Guarantees

  • Stable ordering (updatedAt ASC, id ASC) with a composite cursor on both fields. A timestamp-only cursor would drop rows written in the same millisecond.
  • updatedSince is inclusive. Re-importing an event you already have is harmless (upsert is idempotent); missing one is not recoverable.
  • Scoped to your application. An application only ever sees its own events.
  • All statuses are returned, PENDING included.
  • nextCursor is null on the last page (hasMore: false).

Response

200 OK
{
  "items": [
    {
      "id": "3f9a1c02-7b4e-4c11-9d33-2a5f8e6b0d71",
      "sessionId": "9c2e6f18-13ab-4d75-8f21-6b0c7d9a4e35",
      "amountMsat": "40000",
      "feesMsat": "4000",
      "status": "SUCCESS",
      "createdAt": "2026-08-09T10:00:20.004Z",
      "updatedAt": "2026-08-09T10:00:40.012Z",
      "completedAt": "2026-08-09T10:00:40.012Z",
      "startedAt": "2026-08-09T09:59:58.881Z",
      "payerExternalId": "user_123",
      "applicationId": "b71d4c8e-5a02-4f96-8c17-3e9d0b2a6f54",
      "unitsCharged": 20,
      "resourceId": "res-123",
      "policyId": "e2c7a940-8d61-4b3f-95ea-1c8f7d206b43",
      "currency": "SATS",
      "shareIndex": 0,
      "lostFundsMsat": "0"
    }
  ],
  "nextCursor": "eyJ1cGRhdGVkQXQiOiIyMDI2LTA4LTA5VDEwOjAwOjQwLjAxMloiLCJpZCI6IjNmOWExYzAyIn0",
  "hasMore": true
}

Import loop

// SDK 2.0 — `iterate` follows `nextCursor` until `hasMore === false`
let high = lastSyncedAt

for await (const event of client.debitEvent.iterate({
  updatedSince: lastSyncedAt,
  limit: 500,
})) {
  const netMsat = BigInt(event.amountMsat) - BigInt(event.feesMsat)
  await db.debitEvent.upsert({ where: { id: event.id }, /* ... */ })
  if (event.updatedAt > high) high = event.updatedAt
}

// Persist `high` only at the end of a full cycle.
// A crash mid-cycle replays the window, which the upsert makes harmless.
400 Bad Request
Unreadable cursor
{
  "code": "INVALID_CURSOR",
  "message": "Invalid pagination cursor. Restart the page from `updatedSince`.",
  "details": null
}

A bad cursor always fails loudly — it never silently restarts from the beginning of the collection.

Historical rows. Events whose applicationId is still NULL (session purged and no policy) do not appear in this scoped listing — ownership of a financial row is never guessed. On rows predating the analytics backfill, unitsCharged is 0: exclude them from duration averages rather than counting them as zero-second sessions.

Get Debit Events by User

GET /debit-events/users/:externalUserId

Retrieve all debit events for a specific user with optional date range filtering.

Returns the events of the policies whose beneficiary is this user — at most one per session, since a resource has one policy per beneficiary. SUCCESS events only, and the date range filters on completedAt — so sessions still in progress are excluded. For analytics, prefer Incremental Sync, which returns every status and scales independently of the number of users.

Path Parameters

externalUserId The external ID of the user

Query Parameters

dateFrom optional Start date for filtering (ISO 8601 format)
dateTo optional End date for filtering (ISO 8601 format)

Response

200 OK
[
  {
    "id": "debit-event-uuid-1",
    "sessionId": "session-uuid-1",
    "amountMsat": "100000",
    "feesMsat": "10000",
    "status": "SUCCESS",
    "createdAt": "2026-01-15T10:00:00.000Z",
    "updatedAt": "2026-01-15T10:00:01.000Z",
    "completedAt": "2026-01-15T10:00:01.000Z",
    "startedAt": "2026-01-15T09:59:55.000Z",
    "payerExternalId": "user_123",
    "applicationId": "application-uuid",
    "unitsCharged": 20,
    "policyId": "policy-uuid",
    "resourceId": "resource-id-1",
    "currency": "SATS",
    "shareIndex": 0,
    "lostFundsMsat": "0",
    "policy": {
      "id": "policy-uuid",
      "name": "Premium Video Stream",
      "amountMsat": "1000",
      "stepValue": 5,
      "currency": "SATS",
      "user": {
        "id": "user-uuid",
        "externalId": "user_123"
      },
      "stepUnit": {
        "id": "step-unit-uuid",
        "name": "SECONDS",
        "unitTypeId": "unit-type-uuid"
      }
    }
  },
  {
    "id": "debit-event-uuid-2",
    "sessionId": "session-uuid-2",
    "amountMsat": "50000",
    "feesMsat": "5000",
    "status": "SUCCESS",
    "createdAt": "2026-01-20T14:30:00.000Z",
    "updatedAt": "2026-01-20T14:30:01.000Z",
    "completedAt": "2026-01-20T14:30:01.000Z",
    "startedAt": "2026-01-20T14:29:50.000Z",
    "payerExternalId": "user_123",
    "applicationId": "application-uuid",
    "unitsCharged": 5,
    "policyId": "policy-uuid-2",
    "resourceId": "resource-id-2",
    "currency": "SATS",
    "shareIndex": 0,
    "lostFundsMsat": "0",
    "policy": {
      "id": "policy-uuid-2",
      "name": "Standard Audio Stream",
      "amountMsat": "1000",
      "stepValue": 10,
      "currency": "SATS",
      "user": {
        "id": "user-uuid",
        "externalId": "user_123"
      },
      "stepUnit": {
        "id": "step-unit-uuid",
        "name": "SECONDS",
        "unitTypeId": "unit-type-uuid"
      }
    }
  }
]

Get Debit Events by Resource

GET /debit-events/resources/:resourceId

Retrieve all debit events for a specific resource with optional date range filtering.

Returns one event per policy and per session: a session on a resource with three policies appears three times, once per share. SUCCESS events only, and the date range filters on completedAt. For analytics, prefer Incremental Sync.

Path Parameters

resourceId The ID of the resource

Query Parameters

dateFrom optional Start date for filtering (ISO 8601 format)
dateTo optional End date for filtering (ISO 8601 format)

Response

200 OK
[
  {
    "id": "debit-event-uuid-3",
    "sessionId": "session-uuid-3",
    "amountMsat": "200000",
    "feesMsat": "20000",
    "status": "SUCCESS",
    "createdAt": "2026-01-10T09:15:00.000Z",
    "updatedAt": "2026-01-10T09:15:01.000Z",
    "completedAt": "2026-01-10T09:15:01.000Z",
    "startedAt": "2026-01-10T09:14:59.000Z",
    "payerExternalId": "user_777",
    "applicationId": "application-uuid",
    "unitsCharged": 100,
    "policyId": "policy-uuid",
    "resourceId": "video_stream_001",
    "currency": "SATS",
    "shareIndex": 0,
    "lostFundsMsat": "0",
    "policy": {
      "id": "policy-uuid",
      "name": "Premium Video Stream",
      "amountMsat": "2000",
      "stepValue": 1,
      "currency": "SATS",
      "user": {
        "id": "user-uuid",
        "externalId": "content_creator_456"
      },
      "stepUnit": {
        "id": "step-unit-uuid",
        "name": "SECONDS",
        "unitTypeId": "unit-type-uuid"
      }
    }
  },
  {
    "id": "debit-event-uuid-4",
    "sessionId": "session-uuid-4",
    "amountMsat": "150000",
    "feesMsat": "15000",
    "status": "SUCCESS",
    "createdAt": "2026-01-12T11:20:00.000Z",
    "updatedAt": "2026-01-12T11:20:02.000Z",
    "completedAt": "2026-01-12T11:20:02.000Z",
    "startedAt": "2026-01-12T11:19:58.000Z",
    "payerExternalId": "user_888",
    "applicationId": "application-uuid",
    "unitsCharged": 75,
    "policyId": "policy-uuid",
    "resourceId": "video_stream_001",
    "currency": "SATS",
    "shareIndex": 0,
    "lostFundsMsat": "0",
    "policy": {
      "id": "policy-uuid",
      "name": "Premium Video Stream",
      "amountMsat": "2000",
      "stepValue": 1,
      "currency": "SATS",
      "user": {
        "id": "user-uuid",
        "externalId": "content_creator_456"
      },
      "stepUnit": {
        "id": "step-unit-uuid",
        "name": "SECONDS",
        "unitTypeId": "unit-type-uuid"
      }
    }
  }
]

Get Session Breakdown

GET /debit-events/sessions/:sessionId

What one session cost, and who received it: one line per policy, with the share of one step, the amounts paid, the commission and the lost funds. PENDING and SUCCESS events are both counted. Scoped to your application.

Path Parameters

sessionId The sessionId of the debit events, or of the webhooks. A string of 100 characters at most: old sessions may not have a UUID.

Response

200 OK
{
  "sessionId": "9c2e6f18-13ab-4d75-8f21-6b0c7d9a4e35",
  "status": "CLOSED",
  "resourceId": "res-123",
  "payerExternalId": "user_9",
  "startedAt": "2026-10-20T10:10:00.000Z",
  "currency": "SATS",
  "selfListening": false,
  "pricePerStepMsat": "8000",
  "totalMsat": "16000",
  "totalFeesMsat": "1600",
  "totalNetMsat": "14400",
  "totalLostFundsMsat": "0",
  "unitsCharged": 2,
  "lines": [
    {
      "shareIndex": 0,
      "policyId": "policy-a-uuid",
      "beneficiaryExternalId": "user_a",
      "amountPerStepMsat": "5000",
      "amountMsat": "10000",
      "feesMsat": "1000",
      "netMsat": "9000",
      "lostFundsMsat": "0",
      "unitsCharged": 2,
      "status": "SUCCESS",
      "completedAt": "2026-10-20T10:11:00.000Z"
    },
    {
      "shareIndex": 1,
      "policyId": "policy-b-uuid",
      "beneficiaryExternalId": "user_b",
      "amountPerStepMsat": "2000",
      "amountMsat": "4000",
      "feesMsat": "400",
      "netMsat": "3600",
      "lostFundsMsat": "0",
      "unitsCharged": 2,
      "status": "SUCCESS",
      "completedAt": "2026-10-20T10:11:00.000Z"
    },
    {
      "shareIndex": 2,
      "policyId": "policy-c-uuid",
      "beneficiaryExternalId": "user_c",
      "amountPerStepMsat": "1000",
      "amountMsat": "2000",
      "feesMsat": "200",
      "netMsat": "1800",
      "lostFundsMsat": "0",
      "unitsCharged": 2,
      "status": "SUCCESS",
      "completedAt": "2026-10-20T10:11:00.000Z"
    }
  ]
}
statusACTIVE or CLOSED; null once the session record has been purged — its debit events remain.
pricePerStepMsatPrice of one step recorded at opening (the sum of the shares). null when that record is no longer available; amountPerStepMsat of each line likewise.
selfListeningtrue when the payer is one of the beneficiaries: the session was free, and its lines have "0" amounts and a null status. null when the record is no longer available.
unitsChargedSteps paid by the session — never multiplied by the number of lines.
totalNetMsattotalMsat - totalFeesMsat. Lost funds are reported apart, in totalLostFundsMsat and on each line.
lines[].beneficiaryExternalIdThe beneficiary of the policy — even when its share went to the lost-funds user (lostFundsMsat then says how much).
lines[].statusPENDING while part of the line is not finalized, then SUCCESS with completedAt; null for a line without any debit.

404 DEBIT_SESSION_NOT_FOUND when your application has nothing for this session — the session of another application answers the same. An identifier longer than 100 characters is refused (414). Remains accessible while the application is SUSPENDED (read).

Error Responses

404 Not Found
User or resource not found
{
  "code": "USER_NOT_FOUND",
  "message": "User not found",
  "details": null
}
400 Bad Request
Invalid date format
{
  "code": "VALIDATION_ERROR",
  "message": ["dateFrom must be a valid ISO 8601 date string"],
  "details": null
}

Statistics

Pre-aggregated figures for dashboards, without importing anything.

Summary

GET /stats/summary

Totals, number of billed sessions and unique payers over a period. Scoped to the application owning the API key.

Query Parameters

from optional ISO-8601. Lower bound, inclusive. Omitted ⇒ since the first event.
to optional ISO-8601. Upper bound, inclusive. Omitted ⇒ up to now.

Both bounds apply to createdAt — the only timestamp that is never null, including on historical rows.

Response

200 OK
{
  "totalMsat": "120000",
  "totalFeesMsat": "12000",
  "eventCount": 7,
  "debitEventCount": 11,
  "uniquePayers": 3
}
totalMsatTotal collected, fees included, in msat.
totalFeesMsatCommission taken out of that total, in msat.
eventCountNumber of distinct billed sessions. A session split between several policies counts once.
debitEventCountNumber of debit event lines — one per policy and per session, so larger than eventCount as soon as sessions are split between several policies.
uniquePayersDistinct payerExternalId values.

All statuses are counted, PENDING included — those msat have already moved. uniquePayers ignores events with no payerExternalId, so periods predating the analytics backfill may be under-counted, never over-counted.

WebSocket Streaming

Real-time streaming payments using WebSocket connections.

Overview

WebSocket streaming allows real-time payment streaming based on time. Your application connects to PayEngine's WebSocket server to start, pause, resume, and stop payment streams between users according to your payment policies.

Prerequisites

  • Public key uploaded via PATCH /applications/public-key
  • Valid webhook URL configured
  • JWT token generated with your private key
  • A resource with at least one payment policy, and the paying user

Setup Process

1

Upload Public Key

Upload your RSA public key so PayEngine can verify JWT tokens signed with your private key.

PATCH /applications/public-key
Content-Type: application/json

{
  "publicKey": "-----BEGIN PUBLIC KEY-----\n..."
}
2

Configure Webhook URL

Set your webhook URL to receive payment event notifications.

PATCH /applications/webhook-url
Content-Type: application/json

{
  "webhookUrl": "https://your-app.com/webhooks/payengine"
}
3

Generate JWT Token

Generate a JWT token using your private key for WebSocket authentication.

// Example JWT generation
const jwt = require('jsonwebtoken');

const payload = {
  sub: 'your-app-id',              // Application ID
  resourceId: 'res-123',           // Resource the session pays for
  userExternalId: 'user_payer',    // Payer user external ID
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + (5 * 60) // 5 minutes
};

const token = jwt.sign(payload, privateKey, {
  algorithm: 'RS256',
  keyid: 'your-key-id'  // Optional key identifier
});

The token names the resource, never a policy alone: the session pays every policy of that resource. A token that carries a policyId without resourceId is refused with 4013.

WebSocket Connection

Connection URL

Production: wss://payengine.metronomiq.io/stream
Test: wss://payengine-test.metronomiq.io/stream

Authentication

Include JWT token in connection headers:

const WebSocket = require('ws');

const ws = new WebSocket('wss://payengine.metronomiq.io/stream', {
  headers: {
    'authorization': `Bearer ${jwtToken}`
  }
});

ws.on('open', () => {
  console.log('🟢 WebSocket connection established');
});

ws.on('message', data => {
  const msg = JSON.parse(data);
  console.log('📨 Received:', msg);
});

Token requirements

The token is verified against the RSA public key you registered for the application. It must be signed RS256 — no other algorithm is accepted.

Claim Required Rule
sub yes Your public appId, as a non-empty string.
exp yes Expiry, in epoch seconds. A token without it is refused outright.
iat yes Issued-at. The server also enforces a maximum token age, so a token minted long ago is rejected even if it has not expired.
userExternalId yes The paying user, by your own ID. Must already exist in your application, with a wallet.
resourceId yes The resource the session pays for, by your own ID. It must be linked in your application; the session pays every policy of that resource.
policyId — Deprecated. Ignored when resourceId is present. A token that carries a policyId without resourceId is refused with 4013.

Tokens carry no replay protection: jti, aud and iss are not checked. A leaked token is usable until it expires, so keep lifetimes short — minutes, not hours.

Price of a session

At opening, the payer's balance is checked against the full price of one step — the sum of every policy of the resource, as in pricing.pricePerStepMsat — otherwise the socket closes with 1008. The first step is charged right away, then one step at a time.

When the payer is one of the beneficiaries of the resource, the session is free: no balance check, no debit for anyone, and tick frames carry amountMsat: 0. The split is recorded at opening: changing the resource's policies afterwards does not affect a session in progress.

Browser origins

A browser always sends an Origin header. If it is not on the server's allowlist, the socket is closed with 4403 before the token is even read — so a working server-side test proves nothing about the browser. Ask for your production and staging origins to be allowlisted before you integrate a web player. Clients that send no Origin at all — Node, mobile, server-to-server — are never subject to this check.

Close codes

Every refusal and every ending is reported by a close code. The first two are normal endings — do not count them as errors.

Code Meaning What to do
1000 Client disconnected Normal end of a session. Webhook reason DISCONNECTED.
1001 Server shutting down A deployment, not a fault. Reconnect after a backoff with a fresh token. Webhook reason SHUTDOWN.
1008 No funds The payer's balance is below the price of one step — the sum of every policy of the resource — at opening, or mid-session once it runs out. Top the wallet up. Webhook reason NO_FUND (none when the session is refused at opening).
4001 No token Pass the JWT in the Authorization: Bearer handshake header, or via Sec-WebSocket-Protocol.
4002 Bad token format The token is unreadable, or is missing sub or exp. All three causes share this code — check the claims first.
4003 Key or signature rejected Either no public key is registered for this appId, or verification failed — wrong signature, wrong algorithm, expired, or older than the maximum token age.
4004 No receiver wallet A data problem, not a token problem: a beneficiary of the resource has no wallet, and your application has no reserved user to receive that share in its place. Nothing is charged. Contact support.
4005 No payer wallet No user with that userExternalId in your application, or that user has no wallet. IDs from another application never match, and the reserved user __payengine_lost_funds__ never pays.
4006 Missing userExternalId Add the claim. Note it is checked after the resource, so a token missing both fails as 4007 first.
4007 No policy found The token targets nothing — neither resourceId nor policyId — or the resourceId is not linked in this application.
4008 No ping The client stopped answering ping frames. Browsers and the ws library reply automatically; a custom client must. Webhook reason NO_PING.
4009 Application suspended Your application is suspended: no new session opens, and the REST API answers 403 APPLICATION_SUSPENDED. This is an account state, not a client bug — retrying will not help. Sessions already open are billed until they end.
4010 Backpressure The client was too slow to drain its socket. Long before this close, tick frames are silently dropped to protect memory — so never count frames to know what was billed. Webhook reason BACKPRESSURE.
4011 Resource unlocked The user already owns this resource. Sent when an opening is refused — checked before the balance check, ownership takes precedence over funds — and when a session in progress crosses the threshold, preceded by an ended frame with closeReason: 'UNLOCKED'. Not an error: mark the resource as owned and stop opening payment sessions for it. Webhook reason UNLOCKED.
4012 Webhooks not configured The application's webhook configuration (URL + secret) is missing on the PayEngine side: the session is refused BEFORE any debit — without the STARTED notification the application cannot serve the content, and the prepaid step would be charged for nothing. Operator: set webhookUrl and webhookSecret on the application, then retry.
4013 Token without resource The token carries a policyId but no resourceId. A session always targets a resource, so that every policy of the resource is paid: sign the resourceId into the token. Nothing is charged.
4014 Invalid policy group The policies of the resource cannot be settled together: more policies than the platform allows right now, different rhythms or currencies, or a beneficiary that is reserved or belongs to another application. A configuration or data problem, not a token problem — check the group with GET /resources/{id}/policies, or contact support. Nothing is charged.
4403 Origin not allowed The browser's origin is not allowlisted. Refused before the token is read.
4000 Server error An unexpected failure while establishing the session. Retry with a backoff and report it.

Frames are a convenience, not an accounting record. Under load, tick frames may be dropped and the socket may close — but billing is never throttled by any of this. Never count frames to know what was billed: reconcile against the debit events API, which is the source of truth.

Message Types

Automatic Session Start

The payment session starts automatically when the WebSocket connection is established with a valid JWT token containing resourceId and userExternalId.

Pause Session

Temporarily pause the active payment session

// Send pause message
const pauseMessage = {
  type: 'pause'
};
ws.send(JSON.stringify(pauseMessage));

Resume Session

Resume a paused payment session

// Send resume message
const resumeMessage = {
  type: 'resume'
};
ws.send(JSON.stringify(resumeMessage));

Stop Session

End the payment session (closing WebSocket connection)

// Close the WebSocket to end session
ws.close();

Server frames

PayEngine sends JSON text frames, each carrying a type and the sessionId. There is no generic response envelope.

type Meaning
started The session is open. The first step is charged right after, and announced by a first tick.
tick A step has been billed. paidDelta: length of the step, in seconds. amountMsat (a number): the full price of the step in msat — every share included — or 0 in a free session.
paused Answer to pause. remaining: seconds left in the current step, already paid, used up after the resume.
resume Answer to resume: the session runs again.
ended Sent just before the server closes the socket of an open session, with closeReason — the same values as the reason of the ENDED webhook.
{ "type": "started", "sessionId": "9c2e6f18-13ab-4d75-8f21-6b0c7d9a4e35" }
{ "type": "tick", "sessionId": "9c2e6f18-13ab-4d75-8f21-6b0c7d9a4e35", "paidDelta": 30, "amountMsat": 8000 }
{ "type": "paused", "sessionId": "9c2e6f18-13ab-4d75-8f21-6b0c7d9a4e35", "remaining": 12.5 }
{ "type": "resume", "sessionId": "9c2e6f18-13ab-4d75-8f21-6b0c7d9a4e35" }
{ "type": "ended", "sessionId": "9c2e6f18-13ab-4d75-8f21-6b0c7d9a4e35", "closeReason": "DISCONNECTED" }

In the tick frame, amountMsat is a number, unlike the msat strings of the REST API. Frames may be dropped under load: they inform the client, they never account for what was billed.

Health Check

Two public endpoints report whether PayEngine can serve traffic, and whether it can move money right now. They are the only routes that need no API key, and they are exempt from rate limiting — safe to poll from a load balancer or an uptime monitor.

Live status

Current state of the production API, read from the two endpoints below. Refreshed every minute while this page is open.

  • API /api/health — Checking…

  • Lightning payments /api/health/lightning — Checking…

Last checked: —

GET /api/health

Three dependencies are probed on every call. Two are live round-trips; the third reads a heartbeat, so a hung Lightning node turns the component down on its own after about ninety seconds.

Component What is checked
database A real query against PostgreSQL.
redis A real ping, with the reply verified.
lnd The freshness of the last successful Lightning heartbeat — no network call is made during the request itself.

Everything up

200 OK
{
  "status": "ok",
  "info": {
    "database": { "status": "up" },
    "redis": { "status": "up" },
    "lnd": { "status": "up", "reconnecting": false, "heartbeatAgeMs": 1000 }
  },
  "error": {},
  "details": { "...": "same as info" }
}

At least one component down

503 Service Unavailable
{
  "status": "error",
  "info": { "redis": { "status": "up" } },
  "error": {
    "database": { "status": "down", "message": "unreachable" },
    "lnd": {
      "status": "down", "reconnecting": true, "hasConnection": false,
      "heartbeatAgeMs": 120000, "heartbeatMaxAgeMs": 90000
    }
  },
  "details": { "...": "every component, up or down" }
}

This is the one response that does not follow the standard error envelope described below — it keeps its own shape so that monitoring tools can read it directly. Failure messages stay deliberately vague: no connection string, no node address.

GET /api/health/lightning

Stricter than /api/health: it tells whether the Lightning node can process deposits and withdrawals right now. A node can keep answering simple queries while its payment database is stuck or its view of the blockchain is frozen — this endpoint catches both. While it reports down, PayEngine refuses new deposits and withdrawals with 503 LIGHTNING_UNAVAILABLE instead of starting them.

Reason in degradedReasons Meaning
node-unreachable The node has not answered a heartbeat for longer than allowed (90 seconds by default).
node-database-unresponsive The node's payment database has not answered a read probe recently.
chain-tip-stale The node's block height has not moved for longer than expected (2 hours by default).

Payments available

200 OK
{
  "status": "ok",
  "info": {
    "lightning": {
      "status": "up",
      "blockHeight": 915000,
      "operationalSince": "2026-09-17T09:00:00.000Z"
    }
  },
  "error": {},
  "details": { "...": "same as info" }
}

Payments paused

503 Service Unavailable
{
  "status": "error",
  "info": {},
  "error": {
    "lightning": {
      "status": "down",
      "degradedReasons": ["node-database-unresponsive"],
      "blockHeight": 915000,
      "heartbeatAgeMs": 12000
    }
  },
  "details": { "...": "same as error" }
}

Poll it (once a minute is plenty) to warn your users before they try to deposit or withdraw. /api/health can stay green while this one is down: the service is up, but payments are paused.

Error Handling

Standard error responses and codes used throughout the API.

Error Format

{
  "code": "USER_NOT_FOUND",
  "message": "User not found",
  "details": null
}

The HTTP status carries the category; code is stable and is what your code should branch on — never the message, which may change. details is null, a short diagnostic string, or an object with structured data — for example { "max": 10, "current": 10 } for RESOURCE_POLICY_LIMIT_REACHED.

Authentication & Authorization

Status Code Description
401 UNAUTHORIZED API key missing or invalid
401 INVALID_API_KEY Invalid API key format or value
403 API_KEY_REVOKED API key has been revoked
403 APPLICATION_SUSPENDED Application has been suspended
401 APPLICATION_NOT_AUTHENTIFIED Application authentication failed

Users & Wallets

Status Code Description
404 USER_NOT_FOUND User with specified ID not found
400 USER_ALREADY_EXIST User with this external ID already exists
400 USER_RESERVED The identifier is reserved by PayEngine (prefix __payengine): such a user cannot be created, renamed to, updated or deleted, nor be the beneficiary or the owner of a policy. details.externalId.
400 INSUFFICIENT_BALANCE_WITHDRAW Insufficient balance for withdrawal request

Payments & Lightning

Every answer of a money route tells you where the funds are. The last column is what your integration should do.

Status Code Meaning What to do
400 INVOICE_EXPIRED The invoice to pay is expired, or expires in less than 30 seconds. Nothing was reserved. Ask the recipient for a new invoice.
400 INVALID_INVOICE The bolt11 invoice cannot be read. Check the invoice you sent.
402 INSUFFICIENT_FUNDS The internal balance is too low for this tip or transfer. Nothing moved. Do not retry as is.
409 WITHDRAWAL_ALREADY_IN_FLIGHT This invoice is already being paid, or was already paid. Read the withdrawal state instead of retrying.
409 IDEMPOTENCY_KEY_REUSED This Idempotency-Key was already used for another user, operation or invoice. Use a new key for this new operation.
502 WITHDRAWAL_PAYMENT_FAILED The Lightning payment failed, as confirmed by the node. The reserved amount is back on the balance: nothing was sent. You may retry, with a new Idempotency-Key.
503 LIGHTNING_UNAVAILABLE The Lightning node cannot process payments right now. No deposit invoice was created and no withdrawal was started. Retry later — /api/health/lightning tells you when payments are back.
504 WITHDRAWAL_OUTCOME_PENDING The payment outcome is not known yet (timeout, or payment still in flight). The amount stays reserved; the withdrawal will be completed or refunded after verification. Replay the request with the same Idempotency-Key to read its status. Never retry with a new key.

Payment Policies

Status Code Description
404 PAYMENT_POLICY_NOT_FOUND Payment policy not found in your application. When a group is written, details.policyId names the missing one.
409 PAYMENT_POLICY_USED_BY_RESOURCES Cannot delete the policy: it is attached to resources. details.resourceCount.
409 PAYMENT_POLICY_IN_USE Cannot delete the policy: sessions or debit events reference it, and its history is kept. details.sessions, details.debitEvents.
409 PAYMENT_POLICY_ATTACHED The amount, stepValue or step unit of an attached policy cannot change. details.resourceCount, details.resourceIds (50 at most). See Update Policy for the procedure.
409 PAYMENT_POLICY_BENEFICIARY_IMMUTABLE The beneficiary of a policy never changes. details.current is the current beneficiary.
409 POLICY_BENEFICIARY_WALLET_MISSING The beneficiary of the policy has no wallet: it cannot be attached to a resource. details.policyId, details.userExternalId.
400 STEP_UNIT_NOT_FOUND Unknown stepUnitKeyName. details.stepUnitKeyName.

Resources & Sessions

Status Code Description
404 RESOURCE_NOT_FOUND Resource not found in your application.
400 RESOURCE_ALREADY_EXIST A resource with this identifier already exists in your application.
409 RESOURCE_POLICY_LIMIT_REACHED The group would exceed the number of policies a resource may carry. details.max, details.current.
409 RESOURCE_POLICY_RHYTHM_MISMATCH The policy does not share the step and currency of the group. details.expected, details.got (stepValue, stepUnit, currency), details.policyId.
409 RESOURCE_BENEFICIARY_ALREADY_PRESENT Another policy of the same beneficiary is already in the group. details.userExternalId, details.policyId.
404 RESOURCE_POLICY_NOT_ATTACHED The policy to detach is not in the group of the resource. details.policyId.
409 RESOURCE_LAST_POLICY A resource keeps at least one policy: delete the resource, or replace its group. details.hint.
409 RESOURCE_HAS_MULTIPLE_POLICIES A single-policy form was used on a resource that has several: PATCH /resources/{id} with policyId, or GET /payment-policies/resources/{id}. Use policyIds, or GET /resources/{id}/policies. details.policyCount.
404 DEBIT_SESSION_NOT_FOUND Your application has no data for this session — the session of another application answers the same.

Application Management

Status Code Description
404 APP_NOT_FOUND Application not found
409 DUPLICATE_APP_ID Application ID already exists
400 BAD_PUB_KEY Invalid public key format

System Errors

Status Code Description
400 DB_VALIDATION_ERROR Database validation failed
500 INTERNAL_SERVER_ERROR Internal server error
429 OUT_OF_QUOTA API quota exceeded
500 UNKNOWN Unknown error occurred