Recipe: Register a Merchant
Verify an email, solve a proof-of-work challenge, then get an API key and webhook secret
Register a Merchant
This is the first call in every agent flow, but as of BDT-542 (merged and deployed to production) public/sandbox registration is no longer a single unauthenticated POST /v1/merchants call. It is a four-step flow: verify an email, solve a proof-of-work challenge, then register. Non-sandbox (mainnet-capable) merchant creation still requires a bearer-authenticated user and is out of scope for an agent bootstrapping from nothing.
Read the trust gate section below before writing any code. Completing all four steps correctly does not guarantee registration succeeds — the email has to be allowed in the first place.
Trust gate — read this before you start
Even after completing every step below exactly right, POST /v1/merchants fails with registration is not permitted for this email unless one of these is true:
- the email address is on the
trusted_email_addressesallowlist, or its domain is on thetrusted_email_domainsallowlist (checked live against the database, no deploy needed to change), or - the registration carries a valid
invitation_tokenobtained out-of-band.
This is default-deny by design (anti-abuse). Consumer webmail domains like gmail.com are not trusted by default. A genuinely zero-context agent with an arbitrary email address will NOT be able to self-serve registration today. You need either an email/domain that's already allowlisted, or an invitation token — ask Plaidly for one if you don't have either. Step 1 below always returns 200 with the same response shape regardless of trust status (so the response itself never tells you whether you're trusted), and you will not find out whether your email is permitted until the final POST /v1/merchants call in step 4.
Step 1 — request email verification
curl -X POST https://api.plaidly.io/v1/merchants/email-verification \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"invitation_token": "optional-invitation-token"
}'| Field | Required | Notes |
|---|---|---|
email | yes | Must not belong to a disposable/temporary email provider |
invitation_token | no | Time-limited invitation/enrollment token that satisfies the trust gate on its own |
{
"registration_intent_id": "ri_01HX9K...",
"expires_at": "2026-07-12T13:00:00Z"
}This always returns 200 with this exact shape — whether or not email is trusted, and whether or not it's disposable-checked and rejected before this point. It deliberately does not leak trust-policy membership. A real one-time code is only actually sent to email if it passes the trust check, or you supplied a valid invitation_token. Save registration_intent_id — every remaining step is keyed off it.
Step 2 — confirm the verification code
The code is delivered to the email address from step 1 (or logged server-side in non-production environments). Once you have it:
curl -X POST https://api.plaidly.io/v1/merchants/email-verification/confirm \
-H "Content-Type: application/json" \
-d '{
"registration_intent_id": "ri_01HX9K...",
"code": "123456"
}'{
"registration_intent_id": "ri_01HX9K...",
"verified": true
}Step 3 — request (and solve) a proof-of-work challenge
curl -X POST https://api.plaidly.io/v1/merchants/registration-proof-of-work \
-H "Content-Type: application/json" \
-d '{"registration_intent_id": "ri_01HX9K..."}'{
"required": true,
"challenge_id": "pow_01HX9K...",
"algorithm_version": "sha256-leading-zero-bits-v1",
"challenge": "5f2c...a91b",
"difficulty": 18,
"expires_at": "2026-07-12T13:05:00Z"
}required is false (and no challenge_id/challenge are issued) only when your registration intent carries a valid, unexpired invitation token. Otherwise you must solve the challenge before step 4 will accept the intent.
To solve sha256-leading-zero-bits-v1: find a nonce string such that sha256(challenge + ":" + nonce) has at least difficulty leading zero bits.
import hashlib
def solve_pow(challenge: str, difficulty: int) -> str:
nonce = 0
while True:
candidate = str(nonce)
digest = hashlib.sha256(f"{challenge}:{candidate}".encode()).digest()
leading_zero_bits = 0
for b in digest:
if b == 0:
leading_zero_bits += 8
continue
leading_zero_bits += 8 - b.bit_length()
break
if leading_zero_bits >= difficulty:
return candidate
nonce += 1
nonce = solve_pow("5f2c...a91b", 18)At difficulty around 18-20 bits this takes a small number of seconds on ordinary hardware. nonce can be any string (this example just counts up as a decimal string) — only the resulting hash's leading-zero-bit count matters.
Step 4 — register
curl -X POST https://api.plaidly.io/v1/merchants \
-H "Content-Type: application/json" \
-d '{
"name": "Agent Quickstart Merchant",
"sandbox": true,
"webhook_url": "https://example.com/webhook",
"idempotency_key": "agent-onboard-attempt-1",
"registration_intent_id": "ri_01HX9K...",
"proof_of_work_challenge_id": "pow_01HX9K...",
"proof_of_work_nonce": "482913"
}'| Field | Required | Notes |
|---|---|---|
name | yes | Merchant display name |
sandbox | no (default false) | Must be true for an unauthenticated caller |
webhook_url | no | Set now — there is no separate "update webhook" endpoint yet, see Configure a webhook |
idempotency_key | no | Body field (not a header) scoped to this endpoint only — retry-safe if your first call's response is lost |
registration_intent_id | yes, for public/sandbox registration | The id from step 1, after steps 2 (and, unless invitation-exempt, step 3) are complete. Not used for bearer-authenticated live merchant creation. |
proof_of_work_challenge_id | required unless step 3 returned required: false | The challenge_id from step 3 |
proof_of_work_nonce | required unless step 3 returned required: false | The nonce you solved in step 3 |
Response
{
"id": "mer_01HX9K...",
"name": "Agent Quickstart Merchant",
"sandbox": true,
"api_key": "plaidly_sk_test_...",
"webhook_url": "https://example.com/webhook",
"webhook_secret": "whsec_...",
"rate_limit_per_minute": 60,
"created_at": "2026-07-12T13:06:00Z"
}api_key and webhook_secret are returned exactly once, in this response. There is no "reveal" endpoint — if you lose them, use Rotate credentials to issue new ones (this invalidates the old ones after a short overlap window, it does not recover the originals).
If your email/domain isn't trusted and you have no invitation token, this call fails with registration is not permitted for this email regardless of how correctly you performed steps 1-3 — see the trust gate above.
Save these two values
api_key— send asX-API-Keyon every merchant-scoped request from here onwebhook_secret— use to verifyX-Plaidly-Signatureon incoming webhooks, see Verify a webhook event