Skip to main content
A decision reaches you as a terminal webhook on the single URL your firm registers, and you test each outcome by driving POST /v1/kyc/start with specific preprod input values.
Before this page you need:
  • a public HTTPS endpoint that acknowledges promptly and does its processing afterwards,
  • a webhook secret you generate yourself, format whsec_<base64>, at least 24 bytes,
  • a working start call — see /kyc#start-a-verification,
  • preprod credentials — preprod access does not require a signed agreement, and partners have lost weeks believing it did.

Webhooks

POST /v1/kyc/webhook registers the single URL where we deliver terminal KYC events, and the kyc.approved event is the only authoritative signal that a user can trade.

Registration

One POST /v1/kyc/webhook sets your firm’s webhook. Three properties of it will bite you:
  • One URL per firm. There is no list, no per-event routing and no second URL.
  • Last-writer-wins. A second registration silently replaces the first.
  • Re-registering clears your signing secret. If you re-register to change the URL and do not set the secret again in the same call, you lose signature verification.
[VERIFY] — the registration call cannot be completed from this page alone. The request field names are not published, the success status code is not published, and neither is a documented way to trigger webhook.test. Get all three from your integration lead. Until you have the trigger, treat your first real kyc.approved as your first proof of delivery — and do not discover that in production.

Rotating the secret without a gap

Because re-registration clears the secret, do it in this order:
1

Accept both secrets in your receiver

Verify against the old secret and the new one, accepting either. Deploy that first.
2

Re-register with the new secret

Send the URL and the new secret in the same call. Anything you omit is gone, not preserved.
3

Confirm delivery, then drop the old secret

Wait for a real delivery that verifies against the new secret, then remove the old one.
Rotating in the other order gives you a window where every delivery fails signature verification, and there is no GET to read back what is currently registered, so you cannot tell from our side whether the change took.

The event catalogue

Only terminal events fire. There is no review event, no DocV event, no provisioning event. Every non-terminal state must be polled with GET /v1/kyc/status.
The UAT test plan currently contradicts this and implies a review event exists. It does not. If your test plan says to wait for one, that step cannot pass.
Payloads are snake_case — including event_id — while REST responses on the same flow are camelCase. See /kyc#field-naming-changes-three-times-in-one-flow.
The participant ID on kyc.approved is recorded as participantId, which cuts against the snake_case webhook convention. Accept both spellings until your integration lead confirms the shipped payload schema.

Signature verification

We are Standard Webhooks conformant. The signed content is exactly:
Three rules, each of which has broken a real receiver:
  1. Use the raw request bytes. Verify before you parse. A framework that decodes and re-serializes the JSON changes the bytes and every signature fails.
  2. Use the values from the headers as strings, unmodified. Do not reformat the timestamp.
  3. Compare in constant time.
Your secret is yours: format whsec_<base64>, at least 24 bytes. Generate it with a CSPRNG, store it in your secret manager, and never put it in the repo that serves the endpoint.
Not yet published. The exact delivery header names, the signature value format and whether the HMAC key is the base64-decoded bytes of the secret or the literal whsec_... string are not published. The code below follows the Standard Webhooks specification, which is what conformance implies, and puts both choices in one constant at the top so you can flip them in one place. Confirm with your integration lead against a real delivery before go-live.

Delivery semantics

Delivery is at-least-once. Dedupe on event_id. Store every event_id you have processed and make the handler idempotent — a redelivered kyc.approved must not re-enable trading, re-credit anything, or re-send a user notification. Acknowledge with a 2xx before you do any work, then do the work asynchronously. Verify the signature, dedupe on event_id, enqueue, return. Nothing that can be slow — a write to a cold shard, a downstream call, a user notification — belongs in front of the acknowledgement. A circuit breaker trips on repeated failures, so a slow or erroring receiver stops your deliveries, and with no GET to inspect your registration you will not see that from your side.
[VERIFY] — we publish no delivery timeout. There is no published number for how long we wait for your 2xx before treating a delivery as failed, and no published retry schedule.Get the timeout from your integration lead before you size your receiver. Until you have it, acknowledge on the fast path rather than against a number you assumed.
Not yet published. The circuit breaker’s failure threshold, window, reset behaviour and retry schedule are not published, and neither is whether events buffered while it was open are replayed. Assume a tripped breaker means lost notifications you will have to recover by polling GET /v1/kyc/status for every user in a non-terminal state.
No numeric replay window is published. Our side describes a timestamp as “too old” without quantifying it, so enforce your own window in your receiver — the code above uses 300 seconds, which is your policy, not ours. No outbound IP allowlist is published. Do not design an IP-based control and do not ask your security team to allowlist ours. The signature is the authentication.

Running more than one environment on one URL per firm

What is published is one URL per firm, last-writer-wins. Preprod and production are separate environments with separate credentials and separate keypairs, so you register in each one separately — but the limit itself is stated per firm, not per environment.
[VERIFY] — whether the one-URL limit is counted per firm or per firm per environment is not published. Both readings fit what we have said, and they differ on whether a production registration can clear a preprod one.Confirm it before you run a registration in one environment with credentials that could reach the other, and record every registration on your side either way.
The pattern that works:
  • One receiver per environment, at a distinct hostname, each with its own secret. Never point a production registration at a staging receiver; the terminal approval for a real user would land in a system that cannot act on it.
  • Fan out behind your own URL. If staging, CI and a developer tunnel all need the same events, route them yourself from the single registered receiver. You cannot register a second URL, and trying to share one by re-registering means the last writer wins and the previous team silently stops receiving events.
  • Record every registration on your side — URL, secret fingerprint, environment, timestamp, who ran it. There is no GET to read this back, so your record is the only record you can check.
  • Never re-register from a deploy script. One POST /v1/kyc/webhook in a CI job that runs on every merge will clear the signing secret of whatever was registered before it.

Sandbox fixtures

The fixtures below need a registered preprod webhook of their own — see Webhooks — because three of the four outcomes only become terminal there. Specific input values in preprod drive POST /v1/kyc/start to a chosen outcome, so you can exercise all four branches without real people.
The full fixture table is being regenerated and is not published here. The previously published table of magic dates of birth drifted from the implementation and returned the wrong outcome at least twice, which is worse than having no table: partners wrote passing tests against outcomes the platform no longer produced.Only the fixtures verified below are published. The rest will be republished once they are generated from a test that passes in preprod, not transcribed by hand.

Verified fixtures

This is the shape of the table. Every cell that is not verified is marked as not published rather than filled with a plausible value. Three things to read off it:
  • Paulina Gizela is the manual-review fixture, not the DocV fixture. Partners have used it to test document upload and got a case that sat in review instead. Manual review takes 1–2 business days if you did not send docv_eligible: true.
  • +12125551234 is the DocV fixture. It is the input that returns a docv object.
  • The decision and status literals are not published for any outcome except the approved one, which is decision: ACCEPT with status: CLOSED. That is not a blocker — see /kyc#start-a-verification for branching that never reads those literals.
DocV links in preprod ask for a real ID today. There is no published synthetic document fixture, so finishing the preprod DocV flow means submitting an actual government ID through the hosted flow. Plan your DocV test around that, and do not ask a whole QA team to upload their passports. Confirm the current state with your integration lead before you schedule that testing.

Generate a unique SSN for every test user

This is the single most expensive preprod mistake on this surface. One SSN maps to exactly one exchange account, platform-wide. Reusing one test SSN across several test users points all of them at a single account. Transfers then fail with NOT_FOUND, and untangling it requires manual database cleanup on our side — it has already produced a 66-reply outage thread. Generate a distinct SSN per fixture user, including throwaway ones, and treat HTTP 400 with {"code": 9, "message": "user already provisioned with a different SSN"} as a stop, not a retry. Note that this returns 400, not the documented 409. See /kyc#external-ids-participants-and-accounts.

What to do until the table is rebuilt

1

Drive only the three verified outcomes from fixtures

Manual review, DocV and reject are covered by the fixtures above. Use them.
2

Test the approved path through the webhook, not through a fixture

The approved branch you actually need to get right is “ACCEPT arrives, participantId is empty, trading stays disabled until kyc.approved”. You can test that without a fixture: ignore participantId from the start response entirely and drive enablement from the webhook. The empty window runs from ~500 ms to 26 minutes, so this is the branch that breaks in production, not in tests.
3

Assert on your own state machine, never on our literals

Write your tests against your own user states — unverified, provisioning, tradable, rejected. A test that asserts decision == "ACCEPT" will break when an enum you never saw documented turns up, and "In Review" already proves that happens.
4

Get any fixture you need confirmed by a run, in writing

If you need an outcome the table above does not cover, ask your integration lead to confirm it from an actual preprod run and record the date next to it in your own fixture file. Do not copy values from an older page — that is exactly how the published table drifted.
5

Keep a fallback for the fixtures that stop working

Assume any unverified fixture can change without a changelog entry. Make your test suite report “fixture produced an unexpected outcome” as a distinct failure from “our code is wrong”, so the next drift costs you an hour instead of a day.

Other preprod facts that affect KYC testing

  • There is no preprod status page and no working partner-checkable preprod health endpoint. When preprod misbehaves you cannot distinguish our outage from your bug without asking us. status.polymarketexchange.com covers production only.
  • Preprod pools are funded manually by us on a Slack request. There is no self-service test funding, so plan the funding step of your test run with lead time — see /funding#the-funding-model.
  • {"code":12,"message":"unknown service connamara.ep3.v1beta1.KYCAPI"} in preprod means you called KYC over gRPC. Use REST.

What can go wrong

Next

Legal agreements