> ## Documentation Index
> Fetch the complete documentation index at: https://partners.docs.polymarket.us/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks and sandbox

> One registered URL per firm delivers terminal KYC events, and preprod fixtures let you drive each outcome on demand.

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.

<Info>
  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](/kyc#start-a-verification),
  * preprod credentials — preprod access does **not** require a signed agreement, and partners have lost weeks believing it did.
</Info>

## 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.

```bash theme={null}
set -euo pipefail

BASE_URL="https://api.preprod.polymarketexchange.com"   # prod: https://api.prod.polymarketexchange.com
TOKEN="$(./token.sh)"                                    # canonical token script from /connect#authentication

# VERIFY: the request field names on POST /v1/kyc/webhook for the delivery URL and the
# signing secret are NOT published, so this body cannot be filled in from the docs.
# Get the two field names from your integration lead and put them here. The body
# carries your HTTPS delivery URL and your whsec_ secret, and nothing else.
REGISTRATION_BODY='{}'   # VERIFY: replace with the real field names before running this

# VERIFY: the success status code for this call is not published either, so accept any
# 2xx rather than matching an exact code you were never given.
HTTP="$(curl -sS -o /tmp/webhook-reg-resp.json -w '%{http_code}' \
  -X POST "${BASE_URL}/v1/kyc/webhook" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  --data "${REGISTRATION_BODY}")"

case "${HTTP}" in
  2*) : ;;
  *)  echo "HTTP ${HTTP}"; cat /tmp/webhook-reg-resp.json; exit 1 ;;
esac
echo "registered — record the URL, the secret fingerprint and the timestamp on your side now"
```

<Warning>
  **\[VERIFY] — the registration call cannot be completed from this page alone.**&#x20;
  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.
</Warning>

#### Rotating the secret without a gap

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

<Steps>
  <Step title="Accept both secrets in your receiver">
    Verify against the old secret and the new one, accepting either. Deploy that first.
  </Step>

  <Step title="Re-register with the new secret">
    Send the URL and the new secret in the same call. Anything you omit is gone, not preserved.
  </Step>

  <Step title="Confirm delivery, then drop the old secret">
    Wait for a real delivery that verifies against the new secret, then remove the old one.
  </Step>
</Steps>

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

| Event          | Fires when                    | Carries                                                    |
| -------------- | ----------------------------- | ---------------------------------------------------------- |
| `kyc.approved` | A user is terminally approved | `participantId` — the authoritative source of it           |
| `kyc.rejected` | A user is terminally rejected | No partner-visible decline reason. There is none anywhere. |
| `webhook.test` | A test delivery               | Nothing you act on                                         |

**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`.

<Note>
  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.
</Note>

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](/kyc#field-naming-changes-three-times-in-one-flow).

<Note>
  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.
</Note>

### Signature verification

We are Standard Webhooks conformant. The signed content is **exactly**:

```
<webhook-id>.<webhook-timestamp>.<raw body>
```

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.

<Warning>
  **Not yet published.**&#x20;
  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.
</Warning>

<CodeGroup>
  ```python Python theme={null}
  import base64
  import hashlib
  import hmac
  import os
  import time

  from flask import Flask, request

  # Your secret, format whsec_<base64>, >= 24 bytes. From your secret manager, never the repo.
  WEBHOOK_SECRET = os.environ["PMX_WEBHOOK_SECRET"]
  # Your own replay window. We publish no number; "too old" is unquantified on our side.
  MAX_SKEW_SECONDS = 300

  app = Flask(__name__)
  seen_event_ids = set()  # in production this is your database, not memory

  def signing_key(secret: str) -> bytes:
      # Standard Webhooks: the key is the base64-decoded bytes after the whsec_ prefix.
      return base64.b64decode(secret.removeprefix("whsec_"))

  def verify(raw_body: bytes, webhook_id: str, webhook_timestamp: str, header_sig: str) -> bool:
      if abs(time.time() - int(webhook_timestamp)) > MAX_SKEW_SECONDS:
          return False
      signed = f"{webhook_id}.{webhook_timestamp}.".encode() + raw_body
      expected = base64.b64encode(
          hmac.new(signing_key(WEBHOOK_SECRET), signed, hashlib.sha256).digest()
      ).decode()
      # The header carries a space-separated list of "v<version>,<signature>".
      for part in header_sig.split(" "):
          _, _, candidate = part.partition(",")
          if candidate and hmac.compare_digest(candidate, expected):
              return True
      return False

  @app.post("/pmx/kyc")
  def receive():
      raw = request.get_data()  # raw bytes, before any parsing
      ok = verify(
          raw,
          request.headers.get("webhook-id", ""),
          request.headers.get("webhook-timestamp", "0"),
          request.headers.get("webhook-signature", ""),
      )
      if not ok:
          return "", 401

      event = request.get_json()
      event_id = event["event_id"]
      if event_id in seen_event_ids:
          return "", 200  # at-least-once delivery: dedupe and acknowledge
      seen_event_ids.add(event_id)

      enqueue(event)   # hand off; nothing slow runs before the acknowledgement
      return "", 200

  def enqueue(event):
      print("queued", event["event_id"], event.get("type"))
  ```

  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";
  import express from "express";

  // Your secret, format whsec_<base64>, >= 24 bytes. From your secret manager, never the repo.
  const WEBHOOK_SECRET = process.env.PMX_WEBHOOK_SECRET!;
  // Your own replay window. We publish no number; "too old" is unquantified on our side.
  const MAX_SKEW_SECONDS = 300;

  const seenEventIds = new Set<string>(); // in production this is your database

  // Standard Webhooks: the key is the base64-decoded bytes after the whsec_ prefix.
  const signingKey = Buffer.from(WEBHOOK_SECRET.replace(/^whsec_/, ""), "base64");

  function verify(raw: Buffer, id: string, ts: string, headerSig: string): boolean {
    if (Math.abs(Date.now() / 1000 - Number(ts)) > MAX_SKEW_SECONDS) return false;
    const signed = Buffer.concat([Buffer.from(`${id}.${ts}.`), raw]);
    const expected = createHmac("sha256", signingKey).update(signed).digest("base64");
    // The header carries a space-separated list of "v<version>,<signature>".
    return headerSig.split(" ").some((part) => {
      const candidate = part.slice(part.indexOf(",") + 1);
      const a = Buffer.from(candidate);
      const b = Buffer.from(expected);
      return a.length === b.length && timingSafeEqual(a, b);
    });
  }

  const app = express();
  // Raw bytes, before any parsing. express.json() would change the bytes and break every signature.
  app.post("/pmx/kyc", express.raw({ type: "*/*" }), (req, res) => {
    const ok = verify(
      req.body as Buffer,
      String(req.header("webhook-id") ?? ""),
      String(req.header("webhook-timestamp") ?? "0"),
      String(req.header("webhook-signature") ?? ""),
    );
    if (!ok) return res.sendStatus(401);

    const event = JSON.parse((req.body as Buffer).toString("utf8"));
    if (seenEventIds.has(event.event_id)) return res.sendStatus(200); // dedupe, then acknowledge
    seenEventIds.add(event.event_id);

    console.log("queued", event.event_id, event.type); // do the work asynchronously
    return res.sendStatus(200);
  });

  app.listen(8080);
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/base64"
  	"encoding/json"
  	"io"
  	"math"
  	"net/http"
  	"os"
  	"strconv"
  	"strings"
  	"sync"
  	"time"
  )

  // Your secret, format whsec_<base64>, >= 24 bytes. From your secret manager, never the repo.
  var webhookSecret = os.Getenv("PMX_WEBHOOK_SECRET")

  // Your own replay window. We publish no number; "too old" is unquantified on our side.
  const maxSkewSeconds = 300.0

  var (
  	mu   sync.Mutex
  	seen = map[string]bool{} // in production this is your database
  )

  func signingKey() []byte {
  	// Standard Webhooks: the key is the base64-decoded bytes after the whsec_ prefix.
  	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(webhookSecret, "whsec_"))
  	if err != nil {
  		panic(err)
  	}
  	return key
  }

  func verify(raw []byte, id, ts, headerSig string) bool {
  	sent, err := strconv.ParseFloat(ts, 64)
  	if err != nil || math.Abs(float64(time.Now().Unix())-sent) > maxSkewSeconds {
  		return false
  	}
  	mac := hmac.New(sha256.New, signingKey())
  	mac.Write([]byte(id + "." + ts + "."))
  	mac.Write(raw)
  	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
  	// The header carries a space-separated list of "v<version>,<signature>".
  	for _, part := range strings.Split(headerSig, " ") {
  		if _, candidate, found := strings.Cut(part, ","); found {
  			if hmac.Equal([]byte(candidate), []byte(expected)) {
  				return true
  			}
  		}
  	}
  	return false
  }

  func main() {
  	http.HandleFunc("/pmx/kyc", func(w http.ResponseWriter, r *http.Request) {
  		raw, err := io.ReadAll(r.Body) // raw bytes, before any parsing
  		if err != nil {
  			w.WriteHeader(http.StatusBadRequest)
  			return
  		}
  		if !verify(raw, r.Header.Get("webhook-id"), r.Header.Get("webhook-timestamp"), r.Header.Get("webhook-signature")) {
  			w.WriteHeader(http.StatusUnauthorized)
  			return
  		}

  		var event struct {
  			EventID string `json:"event_id"`
  			Type    string `json:"type"`
  		}
  		if err := json.Unmarshal(raw, &event); err != nil {
  			w.WriteHeader(http.StatusBadRequest)
  			return
  		}

  		mu.Lock()
  		duplicate := seen[event.EventID]
  		seen[event.EventID] = true
  		mu.Unlock()
  		if !duplicate {
  			// Hand off; nothing slow runs before the acknowledgement.
  			go func() { println("queued", event.EventID, event.Type) }()
  		}
  		w.WriteHeader(http.StatusOK) // acknowledge duplicates too
  	})
  	http.ListenAndServe(":8080", nil)
  }
  ```

  ```bash curl theme={null}
  set -euo pipefail

  # Test your own verifier end to end, offline, before you register anything.
  # Your secret, format whsec_<base64>, >= 24 bytes.
  SECRET="${PMX_WEBHOOK_SECRET}"
  RECEIVER="http://localhost:8080/pmx/kyc"   # your endpoint

  WEBHOOK_ID="msg_local_test_0001"
  WEBHOOK_TIMESTAMP="$(date +%s)"
  BODY='{"event_id":"evt_local_test_0001","type":"webhook.test"}'

  # The signed content is exactly "<webhook-id>.<webhook-timestamp>.<raw body>".
  KEY_HEX="$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p | tr -d '\n')"
  SIG="$(printf '%s.%s.%s' "${WEBHOOK_ID}" "${WEBHOOK_TIMESTAMP}" "${BODY}" \
    | openssl dgst -sha256 -mac HMAC -macopt "hexkey:${KEY_HEX}" -binary \
    | base64)"

  HTTP="$(curl -sS -o /tmp/receiver-resp.txt -w '%{http_code}' \
    -X POST "${RECEIVER}" \
    -H "Content-Type: application/json" \
    -H "webhook-id: ${WEBHOOK_ID}" \
    -H "webhook-timestamp: ${WEBHOOK_TIMESTAMP}" \
    -H "webhook-signature: v1,${SIG}" \
    --data-binary "${BODY}")"

  [ "${HTTP}" = "200" ] || { echo "receiver returned HTTP ${HTTP}"; cat /tmp/receiver-resp.txt; exit 1; }

  # Now prove it rejects a bad signature. A verifier that accepts this is not a verifier.
  HTTP="$(curl -sS -o /dev/null -w '%{http_code}' \
    -X POST "${RECEIVER}" \
    -H "Content-Type: application/json" \
    -H "webhook-id: ${WEBHOOK_ID}" \
    -H "webhook-timestamp: ${WEBHOOK_TIMESTAMP}" \
    -H "webhook-signature: v1,AAAA" \
    --data-binary "${BODY}")"

  [ "${HTTP}" = "401" ] || { echo "verifier accepted a bad signature (HTTP ${HTTP})"; exit 1; }
  echo "verifier ok"
  ```
</CodeGroup>

### 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.

<Warning>
  **\[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.
</Warning>

<Warning>
  **Not yet published.**&#x20;
  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.
</Warning>

**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.

<Warning>
  **\[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.
</Warning>

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](#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.

<Warning>
  **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.
</Warning>

### 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.

| Input                      | `decision`             | `status`               | `subStatus`            | `docv` present                                             |
| -------------------------- | ---------------------- | ---------------------- | ---------------------- | ---------------------------------------------------------- |
| Name `Paulina Gizela`      | Not published          | Not published          | Not published          | **No** — this fixture triggers **manual review**, not DocV |
| Phone `+12125551234`       | Not published          | Not published          | `docv_required`        | **Yes** — this is the fixture that starts the DocV flow    |
| Email `reject@example.com` | Not published          | Not published          | Not published          | **No** — this is the reject fixture                        |
| Magic dates of birth       | Withheld — not settled | Withheld — not settled | Withheld — not settled | Withheld — not settled                                     |

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](/kyc#start-a-verification) for branching that never reads those literals.

<Note>
  **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.
</Note>

### 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](/kyc#external-ids-participants-and-accounts).

### What to do until the table is rebuilt

<Steps>
  <Step title="Drive only the three verified outcomes from fixtures">
    Manual review, DocV and reject are covered by the fixtures above. Use them.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

### 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](/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

| Part of this page | Symptom                                                                                | Cause                                                                                 | What you do                                                                                                           |
| ----------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Webhooks          | Deliveries stopped with no error on your side                                          | Someone re-registered. Last-writer-wins, and there is no `GET` to check.              | Re-register from your own recorded values, including the secret. Then find the script or person that did it.          |
| Webhooks          | Every signature fails after a URL change                                               | Re-registering cleared the signing secret                                             | Always send the secret in the same call as the URL. Verify against both secrets during a rotation.                    |
| Webhooks          | Every signature fails from day one                                                     | Your framework parsed and re-serialized the body before verification                  | Verify the raw bytes. Signed content is exactly `<webhook-id>.<webhook-timestamp>.<raw body>`.                        |
| Webhooks          | Signatures fail only for some payloads                                                 | Unicode or key ordering changed on re-serialization                                   | Same fix. Never reconstruct the body.                                                                                 |
| Webhooks          | You are waiting for a "review" or "DocV" event                                         | Only `kyc.approved`, `kyc.rejected` and `webhook.test` fire                           | Poll `GET /v1/kyc/status` for every non-terminal state.                                                               |
| Webhooks          | A user was enabled for trading twice, or double-notified                               | At-least-once delivery, redelivered event                                             | Dedupe on `event_id` and make the handler idempotent.                                                                 |
| Webhooks          | Deliveries dry up after an outage on your side                                         | The circuit breaker tripped on repeated failures                                      | Fix the receiver, then reconcile by polling every user in a non-terminal state. Thresholds are not published.         |
| Webhooks          | An approval arrives with no usable participant ID                                      | The field casing differs from what you parse                                          | Accept both `participantId` and a `snake_case` spelling.                                                              |
| Webhooks          | You cannot confirm registration took                                                   | There is no `GET` for it, and no documented `webhook.test` trigger                    | Ask your integration lead to confirm, and keep your own registration log.                                             |
| Sandbox fixtures  | A fixture returns an outcome your test did not expect                                  | The published fixture table drifted; wrong outcomes have been returned at least twice | Trust only the verified fixtures above. Report the input and the verbatim response so the regenerated table is right. |
| Sandbox fixtures  | `Paulina Gizela` never produces a DocV link                                            | That fixture triggers manual review, not DocV                                         | Use phone `+12125551234` for DocV.                                                                                    |
| Sandbox fixtures  | A DocV fixture case sits for two days                                                  | `docv_eligible: true` was not sent, so it went to manual review                       | Send it on every start call, in every environment.                                                                    |
| Sandbox fixtures  | Transfers fail with `NOT_FOUND` for test users who passed KYC                          | A shared test SSN across fixture users                                                | Unique SSN per user. Cleanup is manual on our side.                                                                   |
| Sandbox fixtures  | `HTTP 400` / `{"code": 9, "message": "user already provisioned with a different SSN"}` | That SSN is already provisioned                                                       | Stop. New SSN for a new user; you cannot reset the existing one.                                                      |
| Sandbox fixtures  | The preprod DocV flow asks for a real government ID                                    | No synthetic document fixture is published                                            | Confirm the current state with your integration lead before scheduling DocV testing.                                  |
| Sandbox fixtures  | Preprod is failing and you cannot tell whether it is you                               | No preprod status page, no working preprod health endpoint                            | Ask in your shared Slack channel and say it is blocking.                                                              |

<Snippet file="support.mdx" />

## Next

[Legal agreements](/legal-agreements)
