PayKeeper holds money in wallets and moves it where you tell it to. This
guide takes you from your first request to a finished payout in the sandbox:
open a wallet, fund it, pay a person, a company and one of your own wallets,
and follow the money through webhooks. You do the maths; PayKeeper pays
exactly the amounts you send and never recalculates them.
Every endpoint and webhook here is in the
API reference in
full. This guide is the order to read it in.
How it fits together
- Open a wallet for each pool of money you account for separately (a
waterfall, say) from one of your organization's presets. - Hand payers its funding instructions. A wallet whose preset gives
wallets their own account numbers (fundingAccount: DEDICATED) has a
virtual account number of its own: payers send to it, and the money is
that wallet's. Otherwise payers send to PayKeeper's escrow account and
must quote the wallet's ten-characterpaymentReference. - Money arrives. PayKeeper places each credit on its wallet and you get
adeposit.postedwebhook. - Disburse. One
POST /disbursementspays up to 100 destinations from a
wallet: people, companies, or your own wallets. PayKeeper finds or creates
each payee on the way and charges its fee on top of what it pays people. - Follow it through. Webhooks tell you as each payout posts and when the
disbursement is done; the GET endpoints are the record you reconcile
against.
Environments
| Environment | Base URL |
|---|---|
| Sandbox | https://app-dev.paykeeper.com/api/public/escrow |
| Production | https://app.paykeeper.com/api/public/escrow |
Build against the sandbox first. It behaves like production with no bank
behind it: you make deposits arrive with POST /sandbox/deposits, and
nothing you do moves real money. Each environment has its own data and its
own credentials.
Authentication
Every request uses HTTP Basic auth: your organization id as the user
name and an access key secret as the password. Whoever manages your
organization's API settings in the PayKeeper app creates and revokes access
keys there. Rotating a key is safe: everything you created with the old key
stays yours under the new one.
export BASE=https://app-dev.paykeeper.com/api/public/escrow
export PK_AUTH="$ORGANIZATION_ID:$ACCESS_KEY_SECRET"
curl -s -u "$PK_AUTH" "$BASE/wallet-presets"The response lists the wallet types your organization can open, with
PayKeeper's fee on each and whether each wallet gets its own account number:
{
"data": [
{
"name": "FUNDING",
"label": "Funding wallet",
"currency": "USD",
"fees": {
"perDisbursement": { "amount": "25.00", "currency": "USD" },
"perDestination": { "amount": "1.00", "currency": "USD" },
"percentage": "0.25"
},
"fundingAccount": "DEDICATED"
}
]
}(Trimmed: each preset also carries its full policy.) PayKeeper sets your
presets and fees up for you; if a request answers 403 public_api_not_enabled, your organization has not been set up yet.
Walkthrough
Set up your webhook endpoint first (see Webhooks). PayKeeper
only records events once your organization has an endpoint: nothing from
before then is sent later, and each wallet's sequenceNumber starts with the
first event after it.
The commands below save what each response gives you in shell variables
(with jq), so you can paste them in order.
1. Open a wallet
curl -s -u "$PK_AUTH" -X POST "$BASE/wallets" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"preset": "FUNDING",
"label": "The Long Quiet, producer waterfall",
"externalId": "wf-40219",
"metadata": { "filmId": "TLQ-2026", "waterfallId": "WF-40219" }
}' | tee wallet.json
export WALLET_ID=$(jq -r .id wallet.json)
export REFERENCE=$(jq -r .fundingInstructions.paymentReference wallet.json)externalId is your own id for the wallet. It is unique within your
organization for good, and anywhere a path takes an id you can use
ext:{externalId} instead, as in GET /wallets/ext:wf-40219. metadata
comes back on every read, and description defaults to the label.
{
"id": "93ba8ed3-67f5-4493-b590-4af0f3f9beed",
"externalId": "wf-40219",
"preset": "FUNDING",
"balances": {
"posted": { "amount": "0.00", "currency": "USD" },
"available": { "amount": "0.00", "currency": "USD" }
},
"fundingInstructions": {
"status": "DEDICATED",
"paymentReference": "CZR593MKZB",
"virtualAccount": {
"accountNumber": "7390001452",
"bankName": "Example Bank, N.A.",
"dateAssigned": "2026-09-23T17:55:36.104Z"
}
}
}(Trimmed.) Keep the wallet's id: everything that moves money takes the
PayKeeper id, never an ext: reference.
Step 5 also pays a holdback into a reserve, which is just another wallet of
yours. Open it the same way:
curl -s -u "$PK_AUTH" -X POST "$BASE/wallets" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"preset": "FUNDING",
"label": "The Long Quiet, recoupment reserve",
"externalId": "wf-40219-reserve"
}' | tee reserve.json
export RESERVE_ID=$(jq -r .id reserve.json)2. Hand payers the funding instructions
curl -s -u "$PK_AUTH" "$BASE/wallets/$WALLET_ID/funding-instructions"wire and ach carry everything a payer's bank asks for, ready to copy
into their payment form. For a DEDICATED wallet their accountNumber is
the wallet's own, and anything credited to it posts to this wallet on its
own, usually within seconds, whether or not the payer quotes the
paymentReference. A wallet's account number is its own for
good: PayKeeper never gives it to another wallet, even after this one
closes, so old instructions can only ever reach the wallet they were written
for.
For a SHARED wallet, payers send to PayKeeper's escrow account and must
put the paymentReference in the wire memo (originator-to-beneficiary
information) or the ACH addenda. That is how PayKeeper knows which wallet
the money is for: a credit quoting it posts to that wallet on its own,
usually within seconds. One whose reference is missing or unreadable is
placed by PayKeeper's operations team, so it posts later.
3. Fund it (sandbox)
In the sandbox, make a wire arrive. Calls that move money require an
Idempotency-Key (see Retries):
curl -s -u "$PK_AUTH" -X POST "$BASE/sandbox/deposits" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d @- <<EOF
{
"paymentReference": "$REFERENCE",
"amount": { "amount": "125000.00", "currency": "USD" },
"rail": "WIRE",
"payerName": "Meridian Sales Agency",
"remittanceInformation": "OBI $REFERENCE Q3 2026 MG THE LONG QUIET",
"description": "Q3 minimum guarantee, territory Benelux"
}
EOF{
"outcome": "MATCHED_BY_REFERENCE",
"matchedWalletId": "93ba8ed3-67f5-4493-b590-4af0f3f9beed",
"transactionId": "d1020340-33e9-4c50-9f7a-c22765e65c2d"
}outcome must be MATCHED_BY_REFERENCE. UNMATCHED means the reference
is not one of your wallets', and nothing was posted.
Most payers to a DEDICATED wallet send to its account number without the
reference. To try that, send accountNumber (the wallet's
virtualAccount.accountNumber) in place of paymentReference; the outcome
is then MATCHED_BY_ACCOUNT_NUMBER.
You get a deposit.posted webhook, and the wallet's balance and statement
show the credit with its rail, payer, memo and bank date
(GET /wallets/{walletId}, GET /wallets/{walletId}/transactions). In
production there is no such call: payers send real money, and PayKeeper
places each credit on its wallet.
4. Invite a payee ahead of time (optional)
You can pay people and companies by naming them right in a disbursement
(step 5). If you want someone set up before they are paid, POST /payees
does the same resolution on its own:
curl -s -u "$PK_AUTH" -X POST "$BASE/payees" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"emailAddress": "[email protected]",
"name": "Ana Reyes",
"ssn": "123-45-6789",
"externalId": "party-5512",
"notify": false
}' | tee ana.json
export ANA_ID=$(jq -r .payeeId ana.json){
"payeeId": "34b19b11-78ac-4b17-b771-7662f0a27b03",
"resolution": "CREATED",
"status": "PENDING_CLAIM",
"ssnLast4": "6789",
"onboardingUrl": "https://app-dev.paykeeper.com/registration/3R28-ZKFZ-D5G9",
"notified": false
}(Trimmed.) onboardingUrl is how Ana claims her PayKeeper account; put it
in your own email to her. PayKeeper only contacts a person itself when you
send notify: true, and never emails companies. Treat the link as a secret.
An email address or mobile number is one person on PayKeeper. When someone
already has the one you send, name must be theirs (a short form such as Ben
for Benjamin is fine), or the request is 409 name_mismatch and nothing is
paid. So an address several participants share, such as a business
manager's, can stand for only one of them: give each person their own, or pay
a company when the money is owed to one. A loan-out, an estate or a trust is a
company payee. The dependable pattern is the one above: resolve each
participant once with your own id as externalId, then pay them by
payeeId.
A person's ssn and a company's ein are optional, and a missing one never
holds a payment into the payee's wallet (onboarding may ask the payee for one
before money can leave it). PayKeeper stores an SSN encrypted, keeps it out of its
logs, and only ever returns its last four digits.
5. Pay everyone
A run's Idempotency-Key is generated once, when you plan the run, and
saved with it before anything is sent. A retry of the run sends that same
key; the next run gets a new one.
export RUN_KEY=$(uuidgen) # save it with the run
curl -s -u "$PK_AUTH" -X POST "$BASE/disbursements" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $RUN_KEY" \
-d @- <<EOF
{
"sourceWalletId": "$WALLET_ID",
"externalId": "run-2026-q3-40219",
"description": "Q3 2026 waterfall distribution",
"destinations": [
{ "payeeId": "$ANA_ID",
"amount": { "amount": "40000.00", "currency": "USD" },
"description": "Composer participation", "externalId": "line-40219-1" },
{ "emailAddress": "[email protected]", "name": "Ben Ortiz",
"amount": { "amount": "15000.00", "currency": "USD" },
"description": "Editor deferment", "externalId": "line-40219-2" },
{ "organizationName": "Harbour Pictures LLC",
"emailAddress": "[email protected]", "name": "Dana Cho",
"ein": "12-3456789",
"amount": { "amount": "25000.00", "currency": "USD" },
"description": "Production company share", "externalId": "line-40219-3" },
{ "walletId": "$RESERVE_ID",
"amount": { "amount": "5000.00", "currency": "USD" },
"description": "Holdback to the recoupment reserve", "externalId": "line-40219-4" }
]
}
EOFA destination names who gets paid, one of:
payeeId: a payee you already have.- A person:
emailAddressand/ormobileNumber, withname(and an
optionalssn). Someone new to PayKeeper is created as a payee on the way. - A company:
organizationNameand a contactemailAddress(and an
optionalein). A company new to PayKeeper is paid into a wallet PayKeeper
holds in its name until its contact finishes setting it up; then that money,
and every later payment, goes to the company's own wallet. organizationId: a company already on PayKeeper.walletId: one of your own wallets, such as a reserve.
Everything is checked before anything is written: ids, the wallet's rules,
every destination, and the balance. The wallet must cover the total plus
PayKeeper's fee, after what earlier disbursements still owe. A refused
request creates nothing, not even a payee. One that fails after that, with a
409 or 503, pays nobody, though a payee it created on the way stays and your retry
finds them again.
{
"id": "4fa9f7be-f95d-49cc-8dfe-b8ee0ed53547",
"externalId": "run-2026-q3-40219",
"status": "PROCESSING",
"amount": { "amount": "85000.00", "currency": "USD" },
"fees": { "amount": "228.00", "currency": "USD" },
"destinations": [
{
"id": "0556f772-8766-4ab5-ab0e-e1cabf58ba94",
"status": "PENDING",
"payeeId": "34b19b11-78ac-4b17-b771-7662f0a27b03",
"onboardingUrl": "https://app-dev.paykeeper.com/registration/3R28-ZKFZ-D5G9"
},
{
"id": "b2f0a679-a244-409e-8f9d-926dd4433cfa",
"status": "PENDING",
"payeeId": "30b5a5ba-ca6a-404b-b7c0-cf90c554ecf4",
"onboardingUrl": "https://app-dev.paykeeper.com/registration/K7QP-4MZT-9HXC"
},
{
"id": "7c1d2e3f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"status": "PENDING",
"payeeId": "9e4a7c21-3b5d-4f6e-8a90-1c2d3e4f5a6b",
"onboardingUrl": "https://app-dev.paykeeper.com/onboarding/6f1e2d3c-4b5a-4968-8776-5a4b3c2d1e0f/auth/HARB-8812-QX4"
},
{
"id": "a8c2e4f6-1b3d-4e5f-9a7c-6d8e0f2b4a19",
"status": "PENDING",
"payeeId": null,
"onboardingUrl": null
}
]
}(Trimmed.) The fee here is the $25.00 per disbursement, plus $1.00 for each
of the three payees, plus 0.25% of the $80,000.00 paid to them: $228.00,
charged on top, so every payee receives exactly what you sent. The $5,000.00
holdback to your own reserve carries no fee; the fee comes when the reserve
pays out. Each leg to someone who has not claimed their account carries their
onboardingUrl for your own email: Ana's is the link from step 4. The legs
come back in the order you sent them.
The fee is worked out on the legs that pay someone. A leg into one of your own
wallets carries none and isn't counted, and a disbursement only between your
own wallets is free. For the rest:
fee = perDisbursement
+ perDestination x (number of those legs)
+ round(percentage% x (their total))
round is half up to the cent. To pay out a whole balance B across n
payees, start from
(B - perDisbursement - n x perDestination) / (1 + percentage / 100),
rounded down to the cent. Because the fee is rounded, the most that fits can
be a cent either side of that, so check: work out the fee on the total as
above, take a cent off while total plus fee is over B, and add one while
the next cent up still fits. available on GET /wallets/{walletId} is the
B to use: it already leaves out what earlier disbursements still owe.
6. Follow it through
As each leg posts you get payout.posted (or payout.failed, with a
failureCode), then one final event for the disbursement:
disbursement.completed, disbursement.partially_completed or
disbursement.failed. The holdback leg to your reserve wallet also sends
transfer.received for the reserve, in its own sequence.
GET /disbursements/{disbursementId} (or GET /disbursements/ext:run-2026-q3-40219)
and GET /wallets/{walletId}/transactions are the record: the statement has
each leg as a DISBURSEMENT line carrying its disbursementId, and the fee
as its own FEE line, which can post a moment after the last leg.
Retries and idempotency
Send an Idempotency-Key header, a UUID you generate, with every POST.
POST /disbursements and POST /sandbox/deposits move money and refuse a
request without one (400 idempotency_key_required). On POST /wallets and
POST /payees it is optional and just as useful.
Generate the key once per operation and save it with the operation (with
your quarter's run, say) before you send it. Reuse it only to retry that same
request with the same body. Keys are compared exactly, capitals included:
uuidgen on a Mac prints capitals and many databases hand a UUID back in
lower case, so send the key exactly as you first sent it, or the retry is a
new request.
| You get | It means | Do this |
|---|---|---|
201 (or 200) with Idempotent-Replayed: true | The first request succeeded; this is its saved response. Nothing happened twice. | Carry on. |
409 idempotency_key_in_use with Retry-After | The first request is still running. | Wait Retry-After seconds, send it again with the same key. |
422 idempotency_key_reused | That key was used for a different request. | It is a bug on your side: a new request needs a new key. |
409 wallet_busy or payee_busy, 503, a timeout, or a dropped connection | Nothing was saved under the key. | Send it again with the same key, after a short backoff. |
500 internal_error | Something failed on PayKeeper's side. | The same: retry with the same key. Quote requestId if it keeps failing. |
Any other 4xx | Refused, and nothing was created. | Fix the request. The same key can be sent again once it is fixed. |
A disbursement whose response you never received, or that answered 500, is
safe to retry with the same key: you get that disbursement back, never a
second one, even when the first request was accepted and only the answer was
lost. Keys are kept for 30
days and belong to your organization, not to an access key, so a retry works
across a key rotation.
externalId is different: it is your permanent id for an object, not a retry
key. Creating a second object with an externalId you already used is 409 external_id_already_exists, however much later.
Webhooks
Set up an endpoint in the PayKeeper app, on your organization's API settings
page: add its URL and choose the Escrow events stream. Each endpoint
carries one stream; the endpoint's signing secret and delivery credential are
shown there. Do it before anything else: events from before your first
endpoint are never sent.
eventType | When |
|---|---|
deposit.posted | A deposit posted to a wallet and counts in its balance. |
deposit.returned | A posted deposit was returned and has left the wallet. |
transfer.received | Money from another PayKeeper wallet posted to the wallet, such as a leg into your own reserve. |
payout.posted | One destination of a disbursement posted: the money is in the payee's wallet. |
payout.failed | PayKeeper canceled or rejected one destination; its money is still in the source wallet. |
disbursement.completed | Every destination of a disbursement posted. |
disbursement.partially_completed | Some destinations posted and the rest failed or were canceled. |
disbursement.failed | No destination posted. |
Every event carries eventId, sequenceNumber, eventType, occurredAt,
walletId and walletExternalId, plus the object it is about, exactly as
the GET endpoints return it, except that a leg's onboardingUrl is always
null in a webhook. The link lets whoever holds it claim the payee's account,
so take it from the create response or GET /disbursements/{disbursementId}.
Verify every delivery
PayKeeper computes HMAC-SHA256 over {timestamp}.{rawBody} with the
endpoint's signing secret and sends the hex digest in
X-PayKeeper-Signature, with X-PayKeeper-Timestamp (Unix seconds) and
X-PayKeeper-Event-Id. Sign the raw body exactly as it arrived, before any
JSON parsing:
import crypto from "node:crypto";
import express from "express";
const app = express();
// Retries repeat the first timestamp for up to 24 hours.
const MAX_AGE_SECONDS = 25 * 60 * 60;
function verified(req) {
const timestamp = req.get("X-PayKeeper-Timestamp") ?? "";
const signature = req.get("X-PayKeeper-Signature") ?? "";
// Check the shape first: timingSafeEqual throws on buffers of different lengths.
if (!/^\d{1,12}$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) {
return false;
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_AGE_SECONDS) {
return false;
}
const expected = crypto
.createHmac("sha256", process.env.PAYKEEPER_SIGNING_SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest();
return crypto.timingSafeEqual(Buffer.from(signature, "hex"), expected);
}
app.post(
"/paykeeper/webhooks",
express.raw({ type: "application/json", limit: "1mb" }),
async (req, res) => {
try {
if (!Buffer.isBuffer(req.body) || !verified(req)) {
// Never 401 or 407: those pause delivery to this endpoint.
return res.sendStatus(403);
}
const event = JSON.parse(req.body.toString("utf8"));
// One step: a unique insert of event.eventId that queues the work only
// when the insert succeeds. Do the work after you answer.
await recordOnceAndEnqueue(event);
res.sendStatus(200);
} catch (err) {
res.sendStatus(500); // PayKeeper retries it
}
}
);The timestamp is when the event was first sent, and a retry repeats it, so
the age check allows 25 hours; eventId is what rejects a replay. Each
delivery also carries the endpoint's delivery credential as HTTP Basic (your
organization id and the credential) or in X-PayKeeper-Key, whichever you
chose. It is not the signing secret, and checking it is optional.
Never answer 401 or 407, even for a signature that doesn't verify. Answer
400 or 403 for that. PayKeeper's delivery service reads 401 and 407 as its
own credentials being rejected and pauses delivery to your endpoint, for
every event, until PayKeeper resumes it. Any other failure only affects that
one event.
Expect duplicates and gaps
- Answer within five seconds with any
2xx, and do the work afterwards.
A timeout, a409, a429or any5xxis retried with exponential
backoff for up to 24 hours. Any other4xxis not retried, and that event
is dropped, so answer5xxor429when you want an event sent again. - Deduplicate on
eventId, atomically. Delivery is at least once, and
can arrive out of order. Two copies of one event can arrive in the same
second, so record the id with a unique insert in the same step that queues
the work; checking first and inserting after lets both through. Keep the
ids you have seen for at least two days. - Use
sequenceNumberto spot a gap. It counts up by one for each
wallet. A missing number may still be retrying, so when you see one,
reconcile that wallet withGET /wallets/{walletId}/transactionsand
GET /disbursements?sourceWalletId=...rather than waiting.
Most events arrive within seconds; one that nothing announces at once, such
as a returned deposit, can take up to about 15 minutes. Use webhooks to know
when to look, and the GET endpoints as the record.
Errors
Every error, authentication included, has the same shape:
{
"error": {
"type": "unprocessable_error",
"code": "insufficient_available_balance",
"message": "Available balance 60000.00 is less than the disbursement total 85000.00 plus PayKeeper's fee 228.00",
"param": null,
"requestId": "c2f1a8e0-4b7d-4e9a-9f3c-1d2e3f4a5b6c"
}
}Branch on code, never on message. param names the field at fault, such
as destinations[2].amount (or destinations[2].walletId for a destination
that is not found), and is null when the fault is in the path. Quote
requestId to PayKeeper support. The codes you will meet most:
| Status | code | What to do |
|---|---|---|
| 400 | idempotency_key_required | Send an Idempotency-Key header. |
| 400 | invalid_amount | Amounts are { "amount": "10.00", "currency": "USD" }, a string with at most two decimals. |
| 400 | external_reference_not_allowed | Money moves by PayKeeper id; use the id, not ext:. |
| 400 | invalid_field | The field named in param is the wrong type, too long, or (an email address) not one. |
| 401 | missing_credentials | Send HTTP Basic credentials: your organization id and an access key secret. |
| 401 | invalid_credentials | Check the organization id and access key secret for this environment. |
| 403 | public_api_not_enabled | Ask PayKeeper to set your organization up. |
| 404 | wallet_not_found | No wallet of yours has that id or ext: reference. |
| 409 | external_id_already_exists | That externalId is taken; GET .../ext:{externalId} returns what has it. |
| 409 | wallet_busy | Another disbursement from this wallet is being created; retry in a moment with the same key. |
| 409 | name_mismatch | That email or mobile number belongs to someone with a different name. |
| 422 | insufficient_available_balance | Fund the wallet, then send it again (the same key works). |
| 503 | virtual_account_unavailable | No dedicated account number is free for a new wallet; nothing was created. Retry later. |
| 503 | upstream_busy | PayKeeper's bank asked it to slow down. Wait Retry-After seconds, then retry with the same key. |
Responses can gain fields, and enumerations can gain values, over time. Treat
one you do not recognize as something to ignore rather than a failure.
Conventions worth knowing
- Money is always
{ "amount": "1250.00", "currency": "USD" }, never a
JSON number.USDis the only currency in this version. - Lists take
limit(1 to 100, default 25) andoffset, and return
{ "data": [...], "total": n, "hasMore": true }, newest first. List
endpoints filter onexternalId. - Tax reporting is yours. PayKeeper does not collect W-9s or file 1099s
for the people and companies you pay. - Money leaves a payee's wallet only after they onboard, which can
include identity verification and PayKeeper's compliance review. PayKeeper may
review a payee or hold a payout at any time; theirstatusreads
UNDER_REVIEWwhile it does. - A returned deposit can leave a wallet below zero. Wires are final, but
an ACH credit can come back after it posts, even once its money has been
paid out. Covering that is the wallet owner's responsibility; new deposits
cover it first, and nothing more can be disbursed until the wallet is back
above zero. - A leg never waits for money. One the wallet can no longer cover (after
a return, say) is canceled withfailureCode: INSUFFICIENT_FUNDS; send it
again once the wallet can cover it. - When payees get paid. A posted leg is in the payee's PayKeeper wallet
at once. It goes on to their bank only once they sign in, link a bank
account and turn on automatic payouts; then each payment goes out by
standard ACH as it arrives, usually landing one to two business days later. - Unclaimed money stays with the payee. Money paid to someone who never
claims their account stays in their PayKeeper wallet until they do; a
posted leg stays posted. - Wires and ACH only. Checks are not accepted.
- US only. Payees are US persons and companies, and amounts are in US
dollars.
Sandbox tips
POST /sandbox/depositsmatches a credit bypaymentReference, for
SHAREDandDEDICATEDwallets alike, or byaccountNumberfor a
DEDICATEDwallet's own number sent without the reference. One that is
not one of your wallets' answersUNMATCHEDand posts nothing.- Disbursements, fees and webhooks run exactly as in production, between
sandbox wallets. - Onboarding links open the sandbox app, and with
notify: truePayKeeper
really sends the email or text, so use contact details you control. - The sandbox's funding instructions come from our banking provider's test
environment and can show a real bank's name and routing number. Never send
real money to them.
Going live
- Production credentials from your organization's API settings page, kept
in your secrets store. - A production webhook endpoint on the Escrow events stream, its
signing secret stored, and every delivery verified. - An
Idempotency-Keygenerated and saved for everyPOSTbefore it is
sent, and retries reusing it. - Your own
externalIdon every wallet, payee, disbursement and leg. - Funding instructions read from the API, never typed by hand.
- A reconciliation job that compares each wallet's statement with your
books, run wheneversequenceNumberskips.
What is not in this version
- No cancellation or amendment of a disbursement once accepted; ask PayKeeper
support to cancel a leg that is waiting for funds. - Wallets cannot be renamed, updated or closed through the API; ask
PayKeeper. - Webhook endpoints are set up in the PayKeeper app, and there is no API to
list or replay past events. fundingLink, a page with a wallet's instructions to send a payer, is in
the response butnullfor now.
