Plaidly Docs

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_addresses allowlist, or its domain is on the trusted_email_domains allowlist (checked live against the database, no deploy needed to change), or
  • the registration carries a valid invitation_token obtained 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"
  }'
FieldRequiredNotes
emailyesMust not belong to a disposable/temporary email provider
invitation_tokennoTime-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"
  }'
FieldRequiredNotes
nameyesMerchant display name
sandboxno (default false)Must be true for an unauthenticated caller
webhook_urlnoSet now — there is no separate "update webhook" endpoint yet, see Configure a webhook
idempotency_keynoBody field (not a header) scoped to this endpoint only — retry-safe if your first call's response is lost
registration_intent_idyes, for public/sandbox registrationThe 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_idrequired unless step 3 returned required: falseThe challenge_id from step 3
proof_of_work_noncerequired unless step 3 returned required: falseThe 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 as X-API-Key on every merchant-scoped request from here on
  • webhook_secret — use to verify X-Plaidly-Signature on incoming webhooks, see Verify a webhook event

Next

On this page