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
https://payengine.metronomiq.io/api
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— formatappId:secret, sent as thex-api-keyheader on every REST call.rotateKey— the only way to rotate the API key later.webhookSecret— HMAC key used to verify incoming webhooks.
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.
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.
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.
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 →
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.
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
{
"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.statusisPENDINGwhile the payment is still in flight, thenSUCCESS— orFAILEDif 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_PENDINGand may need a manual review.
Replayed withdrawal
{
"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-keyRequired for WebSocket Streaming
Upload your RSA public key in PEM format.
Request
{
"publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----"
}
Response
Set Webhook URL
PATCH /applications/webhook-urlConfigure the URL where PayEngine will send webhook notifications.
Request
{
"url": "https://your-app.com/webhooks/payengine"
}
Response
Refresh Webhook Secret
GET /applications/refresh-webhook-secretGenerate a new webhook signing secret for security.
Response
{
"newWebhookSecret": "new_webhook_secret"
}
Rotate API Key
POST /applications/rotate-keyGenerate a new API key for enhanced security. Use the Authorization header with your rotate key.
Headers
Authorization: RotateKey your_rotate_key
Response
{
"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 /usersCreate a new user with an internal wallet.
Request
{
"externalId": "user_123"
}
Response
{
"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 /usersList all users in your application.
Response
[
{
"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
{
"id": "user-uuid-here",
"externalId": "user_123",
"feePercent": 10,
"applicationId": "application-uuid-here",
"walletId": "wallet-uuid-here",
"kind": "STANDARD"
}
Tipping
POST /users/{id}/tipsSend 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 useramount(number, required) - Amount to tip in satoshiscurrency(string, required) - Must be "SAT"
Response
{
"transactionId": "transaction-uuid-here",
"fromUserId": "user_123",
"toUserId": "user_456",
"amountMsat": "1000000",
"appFeeMsat": "100000",
"status": "SUCCESS"
}
Create Deposit Invoice
POST /users/{id}/depositGenerate 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
{
"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 paymentPAID- Invoice has been paid successfullyEXPIRED- 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/previewPreview fees and check balance before withdrawal.
Path Parameters
id: User external ID (e.g., "user_123")
Request
{
"invoice": "lnbc1000n1pwg..."
}
Response
{
"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}/withdrawPay 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
{
"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,SUCCESSorFAILED). - 400
INVOICE_EXPIRED, 503LIGHTNING_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}/balanceCheck a user's wallet balance.
Path Parameters
id: User external ID (e.g., "user_123")
Response
{
"balance": {
"balanceMsat": "5000000",
"balanceSat": "5000"
}
}
/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
| payableOnly | true ⇒ 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. |
| status | PENDING | 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}/statusCheck the status of a deposit invoice.
Path Parameters
id: User external ID (e.g., "user_123")
paymentHash: Payment hash from deposit invoice
Response
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 presentamountMsat?- Present when status is PENDING, PAID, or EXPIREDamountSat?- Present when status is PENDING or PAIDdescription?- Present when status is PENDING or PAIDexpiresAt?- Present when status is PENDING, PAID, or EXPIREDcreatedAt?- Present when status is PENDING or PAIDwalletId?- 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 paymentPAID- Invoice has been paid successfullyEXPIRED- Invoice has expired without paymentNOT_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
{
"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
{
"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-policiesCreate 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
{
"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-policiesRetrieve all payment policies in your application.
Response
[
{
"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
{
"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
[
{
"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
[
{
"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
{
"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,stepValueorstepUnitKeyNameof a policy attached to at least one resource is refused with409 PAYMENT_POLICY_ATTACHED;detailsgivesresourceCountand up to 50resourceIds. Sending the current value is accepted. - Beneficiary.
externalUserIdis accepted only when it equals the current beneficiary; any other value is refused with409 PAYMENT_POLICY_BENEFICIARY_IMMUTABLE(details.current). idin the body. Tolerated, because older SDKs send it; it must equal theidof the URL, otherwise400 VALIDATION_ERROR.- Other errors.
404 PAYMENT_POLICY_NOT_FOUNDwhen the policy does not exist in your application;400 STEP_UNIT_NOT_FOUNDfor an unknown step unit;404 USER_NOT_FOUNDor400 USER_RESERVEDfor 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:
- Replace the group (recommended): create a new policy at the new price, then send
PATCH /resources/{id}withpolicyIdslisting the new policy in place of the old one. The swap is atomic, resource by resource. - Detach, update, re-attach:
DELETE /resources/{id}/policies/{policyId}on every resource listed indetails.resourceIds,PATCHthe policy, thenPUT /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
{
"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"
}
{
"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
{
"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 contentvideo_67890- for videosstream_abc123- for live streamsarticle_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 — otherwise409 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
policyIdkept 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 /resourcesEvery resource of your application, with its group of policies and the price of a step, sorted by externalResourceId.
Response
[
{
"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/policiesRead 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
[
{
"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 /resourcesCreate 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
{
"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.
policyIdsreplaces the whole group, atomically: the rules of a group apply to the new set, and when they refuse it the previous group stays intact.policyIdreplaces the policy of a resource that has at most one. From two policies on it is refused with409 RESOURCE_HAS_MULTIPLE_POLICIES(details.policyCount), so that an older client never overwrites a group.- Sending both
policyIdandpolicyIdsis refused (400 VALIDATION_ERROR). An empty body answers404 PAYMENT_POLICY_NOT_FOUND. - When the resource does not exist, a request carrying
policyIdcreates it (behavior kept for compatibility).policyIdsnever creates: it answers404 RESOURCE_NOT_FOUNDand 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, usePOST /resources. WithunlockThresholdMsatalone, the answer is also404 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
{
"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}/policiesThe 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
{
"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.pricePerStepMsat | Price 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.maxPolicies | How 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[].position | Order 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.
{
"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
{
"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
[
{
"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
{
"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.
MINUTES
Ideal for services billed per minute, like voice calls or consultations.
HOURS
Best for hourly services, professional consultations, or long-term access.
Get All Unit Types
GET /unitsRetrieve all available unit types with their associated step units.
Response
[
{
"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}/stepsGet all step units for a specific unit type.
Path Parameters
name: Unit type name (e.g., "TIME")
Response
[
{
"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
| schemaVersion | number (2) — lets two payload formats coexist. |
| eventId | uuid, unique per emission. Deduplicate on it — this is mandatory. Delivery is retried, so receiving the same event twice is normal rather than exceptional. |
| emittedAt | ISO-8601 send time. Distinct from timestamp (business time). On a retry it stays the time of the first attempt. |
| event | STARTED | PAUSED | RESUMED | ENDED |
| sessionId, payerId, payerExternalId, policyId, resourceId, timestamp | Session context — who pays for which resource. policyId is the first policy of the session's split. |
| policyIds | array 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
| PAUSED | remaining (seconds left in the current step) |
| ENDED | reason, 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 |
|---|---|---|
| 1000 | DISCONNECTED | Normal end / client disconnect. |
| 1001 | SHUTDOWN | Server shutting down (a deployment, not a fault). Reconnect with a fresh token. |
| 1008 | NO_FUND | Insufficient balance. |
| 4008 | NO_PING | Heartbeat expired. |
| 4010 | BACKPRESSURE | Client too slow (server memory protection). |
| 4011 | UNLOCKED | Cumulative spend crossed the unlock threshold — the resource is now owned. Not an error: mark it owned and stop opening payment sessions for it. |
| other | UNEXPECTED_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 byamountMsat/feesMsat(string, msat). The canonical unit is the msat, held as aBigIntserver-side — not representable in JSON, and beyondNumber.MAX_SAFE_INTEGERfor large balances. UseBigInt(value)for arithmetic, never floats. statusno longer hasFAILED. OnlyPENDINGandSUCCESSremain. 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 (nohmac_sha256=prefix). See Webhook Events. - Date fields are ISO-8601 strings, not
Dateobjects. 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 |
|---|---|---|
| id | uuid | Upsert key on the consumer side. |
| sessionId | string | Originating session. Survives session purge. |
| policyId | uuid | null | The policy this line pays: one line per policy and per session. null only on historical rows. |
| shareIndex | int | Rank of the policy in the session's split, 0 for the first. 0 on every row recorded before split payment. |
| amountMsat | string (msat) | Share paid for this policy, fees included. net = amountMsat - feesMsat. |
| feesMsat | string (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. |
| lostFundsMsat | string (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. |
| status | PENDING | SUCCESS | Mutable. See lifecycle below. |
| createdAt | ISO-8601 | Instant of the first settled step — not the start of the session. |
| updatedAt | ISO-8601 | Last mutation. Key to incremental sync. |
| completedAt | ISO-8601 | null | Instant of PENDING → SUCCESS (session close). Not a Lightning confirmation. |
| startedAt | ISO-8601 | null | Start of the session (WebSocket open). Use for hourly series. |
| payerExternalId | string | null | Paying-user id on the application side. |
| applicationId | uuid | null | Owning application — the scoping column of the listing. |
| unitsCharged | int | Steps 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. |
| resourceId | string | null | Resource billed by the session. |
| currency | SATS | USDT | Currency 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
- On the first settled step, one row per policy of the session is created as
PENDINGin the same transaction as the wallet-to-wallet transfers. APENDINGrow therefore means the msat have already moved. - On each following step,
amountMsat,feesMsat,lostFundsMsatandunitsChargedare incremented on those same rows (andupdatedAtadvances). Every row of a session moves together: a step pays every share, or none. - On session close, the rows flip to
SUCCESSwithcompletedAtset. 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-eventsList 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. updatedSinceis 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,
PENDINGincluded. nextCursorisnullon the last page (hasMore: false).
Response
{
"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.
{
"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/:externalUserIdRetrieve 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
[
{
"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/:resourceIdRetrieve 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
[
{
"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/:sessionIdWhat 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
{
"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"
}
]
}
| status | ACTIVE or CLOSED; null once the session record has been purged — its debit events remain. |
| pricePerStepMsat | Price of one step recorded at opening (the sum of the shares). null when that record is no longer available; amountPerStepMsat of each line likewise. |
| selfListening | true 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. |
| unitsCharged | Steps paid by the session — never multiplied by the number of lines. |
| totalNetMsat | totalMsat - totalFeesMsat. Lost funds are reported apart, in totalLostFundsMsat and on each line. |
| lines[].beneficiaryExternalId | The beneficiary of the policy — even when its share went to the lost-funds user (lostFundsMsat then says how much). |
| lines[].status | PENDING 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
{
"code": "USER_NOT_FOUND",
"message": "User not found",
"details": null
}
{
"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/summaryTotals, 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
{
"totalMsat": "120000",
"totalFeesMsat": "12000",
"eventCount": 7,
"debitEventCount": 11,
"uniquePayers": 3
}
| totalMsat | Total collected, fees included, in msat. |
| totalFeesMsat | Commission taken out of that total, in msat. |
| eventCount | Number of distinct billed sessions. A session split between several policies counts once. |
| debitEventCount | Number of debit event lines — one per policy and per session, so larger than eventCount as soon as sessions are split between several policies. |
| uniquePayers | Distinct 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
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..."
}
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"
}
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
wss://payengine.metronomiq.io/stream
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
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: —
/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
{
"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
{
"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.
/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
{
"status": "ok",
"info": {
"lightning": {
"status": "up",
"blockHeight": 915000,
"operationalSince": "2026-09-17T09:00:00.000Z"
}
},
"error": {},
"details": { "...": "same as info" }
}
Payments paused
{
"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 |