Receive terminal KYC outcomes (approved / rejected) via signed webhooks.
BETA — SUBJECT TO CHANGE. This API is in beta and may change without notice.
KYC outcomes that resolve after the initial request — instant approvals (whose account provisioning completes asynchronously), DocV results, and manual-review decisions — are delivered to a webhook URL you register. Webhooks are how you learn a participant’s final provisioned provisioned_participant and any rejection.
Only terminal outcomes are delivered. Intermediate states (DocV in progress, manual review) produce no webhook — poll GET /v1/kyc/status if you need to track them.
POST /v1/kyc/webhook — requires the write:kyc scope. Register once per firm (not per participant). Registration runs a test POST to your URL before saving; if the test fails, nothing is saved and you may retry.
Your Standard Webhooks signing secret in whsec_<base64-key> format, where the decoded key is at least 24 bytes (e.g. whsec_ followed by 32 random bytes in standard padded base64). Omit to receive unsigned webhooks.
You bring your own signing secret. Polymarket US never generates or returns a secret — you supply and keep it. Use the Standard Webhooks whsec_<base64-key> format (e.g. whsec_ followed by the output of openssl rand -base64 32). Registration is last-writer-wins: re-registering without a signing_secretclears any previously stored one (switching you to unsigned).
Registration sends a POST to your URL with a normal notification envelope whose event_type is webhook.test and whose data is a fixed informational message. It is signed (same scheme as real deliveries) when you supplied a signing_secret. Your endpoint passes only by returning a 2xx. Redirects are not followed, and URLs that resolve to private or internal IP ranges are refused.
The identifier you supplied at start. Your correlation key; matches status.externalId and the GET /v1/kyc/status lookup
user_id
both
Same value as external_id (legacy field name)
kyc_eval_id
both
Verification-provider evaluation id, carried under its own field. Optional — present only when the provider id is exposed. Never use it as your correlation key
firm_id
both
Your firm ID
status
both
KYC_STATUS_APPROVED or KYC_STATUS_REJECTED
status_set_at
both
RFC 3339 timestamp of the decision
provisioned_participant
kyc.approved
Engine-neutral participant identifier — opaque string. The only provisioning identifier you need
date_of_birth
kyc.approved
The participant’s date of birth may be included. Treat as sensitive PII
When you place an order on behalf of a participant, provisioned_participant is the “who” — pass it as the x-participant-id header on participant-scoped requests (trading, positions, reports). It is the only provisioning identifier you need.
Send as the x-participant-id header — this is who the order is for
external_id / user_id
status.externalId
your own string
Your correlation key only — store it to map back to your user. Never sent to Polymarket US to identify the participant on a trading call
Use provisioned_participant, not external_id / user_id, to identify the participant on a trading call. The external_id (and its legacy duplicate user_id) is the identifier you supplied — it is meaningful only inside your own systems. The exchange identifies the participant by the provisioned_participant value carried in the x-participant-id header.
The webhook and GET /v1/kyc/status carry the same value under different field names — the webhook calls it provisioned_participant, while the status read calls it participantId.
# Placing an order for the participant from a kyc.approved webhookcurl -X POST https://api.polymarket.us/v1/orders \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -H "x-participant-id: firms/ISV-Participant-YourFirmID/users/your-internal-user-id-123" \ -d '{ "symbol": "tec-nfl-sbw-2026-02-08-kc", "side": "SIDE_BUY", "order_qty": 100, "price": 550, "type": "ORDER_TYPE_LIMIT", "time_in_force": "TIME_IN_FORCE_GOOD_TILL_CANCEL" }'
At-least-once. Deduplicate on event_id (the webhook-id header) — you may receive the same event more than once.
Retries. Only a 2xx counts as delivered. Redirects are not followed (a 3xx is a failed delivery); every non-2xx or transport error is retried with jittered exponential backoff up to a maximum attempt count. Retry-After is honored on 408 / 429. A repeatedly-failing endpoint is circuit-broken: deliveries pause for a cooldown that grows with each consecutive re-open, then resume.
Respond fast. Return 2xx promptly once you’ve durably accepted the event; do heavy processing asynchronously.
Because the secret is in the standard whsec_/base64 format, a Standard Webhooks SDK verifies deliveries out of the box — initialise it with the default constructor (e.g. NewWebhook in Go), passing the secret exactly as you registered it. No raw-key escape hatch (NewWebhookRaw) is needed.If you verify inline instead, the signed content is <webhook-id>.<webhook-timestamp>.<raw body> — the raw request bytes, before any JSON re-serialization. The HMAC key is the decoded Standard Webhooks key: strip the optional whsec_ prefix from your signing_secret and base64-decode the remainder using standard padded base64. Compute the expected signature, then constant-time compare it against each space-delimited candidate in webhook-signature; accept if any matches.
// Derive the key: drop the optional "whsec_" prefix, then base64-decode.key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))if err != nil { /* reject: malformed secret */ }// signed content: "<webhook-id>.<webhook-timestamp>." + raw bodymac := hmac.New(sha256.New, key)mac.Write([]byte(id + "." + timestamp + "."))mac.Write(body)expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))for _, candidate := range strings.Fields(signatureHeader) { if hmac.Equal([]byte(candidate), []byte(expected)) { return true // valid }}return false // reject
Reject deliveries whose webhook-timestamp is too old to limit replay exposure, and always verify against the raw body you received rather than a re-encoded copy.