Diagnose and resolve 400 Bad Request errors during clickwrap (clickthrough) contract submission via the SpotDraft SDK or public API. Use this skill when a customer reports intermittent or consistent 400 errors while calling the clickwrap execute/submit endpoint, 'invalid payload' errors, 'user_identifier missing or invalid', null field errors, 400 on agreement submission, clickwrap contract not being created, or SDK submit() returning a 400. Also trigger on: 'clickthrough 400', 'clickwrap API validation', 'SDK validation error', 'null user_identifier', 'invalid user_email', 'agreement ID mismatch', 'clickwrap submission fails', or 'TnC packet 400'.
Covers 400 Bad Request errors raised when a customer's integration calls the clickwrap execute (submit) API — POST /api/v2.1/clickwraps/{clickwrap_public_id}/consent/. The most common root cause is null, empty, or invalid payload fields sent by the client integration.
POST /api/v2.1/clickwraps/{clickwrap_public_id}/consent/
Auth: ExternalClickwrapAuthentication (API key in header)
Server-side call chain:
CreateClickwrapContractView.post()
└── CreateClickwrapContractRequest(**request.data) ← Pydantic validation (400 raised here for invalid fields)
└── CreateClickwrapContractUseCase.execute()
├── _validate() ← Feature flag check (raises 403, not 400)
├── _create_executed_contract() ← Creates ContractV3 of kind CLICKWRAP
├── _get_or_create_clickwrap_user() ← Upsert ClickwrapUser by user_identifier + wsid
├── _create_clickwrap_consent() ← Creates ClickwrapConsent record
├── _create_clickwrap_consent_agreement_version_mapping() ← Links consent to agreement versions
└── peripherals task (async) ← Email, key pointers, audit trail
Key files:
public/clickwraps/clickwrap/presentation/create_clickwrap_contract_view.pyclickwraps/clickwrap_consent/domain/domain_models.py — CreateClickwrapContractRequestclickwraps/clickwrap_consent/domain/use_cases/create_clickwrap_contract.pyclass CreateClickwrapContractRequest:
clickwrap_public_id: UUID # REQUIRED — the UUID from the clickthrough URL
user_identifier: str # REQUIRED — non-null, non-empty string (Pydantic rejects null)
agreements: List[{id, version_id, has_clicked}] # REQUIRED — must reference mapped agreement versions
first_name: Optional[str] = None # Optional — send empty string fallback instead of null
last_name: Optional[str] = None # Optional — send empty string fallback instead of null
user_email: Optional[str] = None # Optional — OMIT if null or invalid; sending invalid email triggers 400
additional_custom_information: Optional[dict] = None # Optional — avoid null values within the dict
key_pointer_information: Optional[dict] = None # Optional — avoid null values within the dict
external_metadata: Optional[dict] = None
400 trigger matrix:
| Field | Sent As | Result |
|---|---|---|
user_identifier | null / None | 400 — Pydantic rejects (field is required str) |
user_identifier | "" (empty string) | 400 or silent bug — passes Pydantic but creates bad contract name; SDK should reject before API call |
user_email | "not-an-email" or null | 400 if email validation is applied downstream |
agreements | [] (empty list) | 400 — no agreement version mappings to create |
agreements[].version_id | invalid/unmapped ID | 400 — foreign key constraint failure |
| Optional dict fields | {"key": null} | Potentially 400 — avoid null values inside dicts |
baseUrl | trailing slash | SDK builds malformed endpoint URL → 400 or 404 |
submit() called before init() | race condition | clickwrap_public_id / agreement IDs are wrong → 400 |
Check incoming request logs for the failed submissions. Prod India cluster is prod-india.
Find 400 errors for a specific workspace (Groundcover log query):
service = "spotdraft-django-app"
AND cluster = "{cluster}" -- e.g. prod-india
AND http.status_code = 400
AND http.path CONTAINS "/clickwraps/"
AND workspace_id = "{wsid}"
Find the exact validation error message:
service = "spotdraft-django-app"
AND cluster = "{cluster}"
AND http.path CONTAINS "/clickwraps/"
AND http.path CONTAINS "/consent/"
AND http.status_code = 400
⚠️ Prod uses jsonPayload, QA uses textPayload.
Find 400 errors for clickwrap consent endpoint in prod (narrow time window required — high volume):
logName="projects/spotdraft-prod/logs/stderr"
resource.labels.cluster_name="prod-india"
jsonPayload.message:"clickwrap"
jsonPayload.message:"400"
timestamp >= "{start_ts}"
timestamp <= "{end_ts}"
Find CreateClickwrapContractUseCase log entries for a specific user_identifier:
logName="projects/spotdraft-prod/logs/stdout"
resource.labels.cluster_name="prod-india"
jsonPayload.user_identifier="{user_identifier}"
jsonPayload.message:"CreateClickwrapContractUseCase called"
QA equivalent:
resource.type="k8s_container"
labels."k8s-pod/app"="spotdraft-qa-django-app"
textPayload:"CreateClickwrapContractUseCase"
textPayload:"{user_identifier}"
⚠️ Prod India uses spotdraft-prod project, dataset prod_india_db, table prefix public_.
Environment-to-dataset mapping:
| Cluster | BQ Project | Dataset | Table Prefix |
|---|---|---|---|
| Prod India (IN) | spotdraft-prod | prod_india_db | public_ |
| Prod USA | spotdraft-prod | prod_usa_db | public_ |
| Prod EU | spotdraft-prod | prod_eu_db | public_ |
| QA India | spotdraft-qa | qa_india_public | (none) |
Look up clickwrap by public_id (from the clickthrough URL):
SELECT
cw.id, cw.name, cw.public_id, cw.contract_type_id,
cw.is_deleted, cw.tenant_workspace_id, cw.created, cw.modified
FROM `spotdraft-prod.prod_india_db.public_clickwraps_clickwrap` cw
WHERE cw.public_id = '{clickwrap_public_id}'
AND cw.tenant_workspace_id = {wsid}
Check agreements mapped to a clickwrap:
SELECT
cam.id, cam.clickwrap_id, cam.clickwrap_agreement_id,
ca.name as agreement_name, ca.is_deleted as agreement_deleted,
cav.id as version_id, cav.name as version_name,
cav.is_published, cav.is_deleted as version_deleted
FROM `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapagreementmapping` cam
JOIN `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapagreement` ca
ON ca.id = cam.clickwrap_agreement_id
JOIN `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapagreementversion` cav
ON cav.clickwrap_agreement_id = ca.id AND cav.is_deleted = FALSE
WHERE cam.clickwrap_id = {clickwrap_id}
AND cam.is_deleted = FALSE
ORDER BY cav.is_published DESC, cav.id DESC
Check consent records created for a user_identifier (to see which submissions succeeded):
SELECT
cu.user_identifier, cu.user_email, cu.first_name, cu.last_name,
cu.tenant_workspace_id,
cc.id as consent_id, cc.contract_id,
cc.clickwrap_id, cc.consented_at, cc.ip,
c.workflow_status, c.execution_date
FROM `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapuser` cu
JOIN `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapconsent` cc
ON cc.clickwrap_user_id = cu.id
JOIN `spotdraft-prod.prod_india_db.public_contracts_v3_contractv3` c
ON c.id = cc.contract_id
WHERE cu.user_identifier = '{user_identifier}'
AND cu.tenant_workspace_id = {wsid}
ORDER BY cc.consented_at DESC
LIMIT 20
Count submission successes vs failures by checking consent records for a workspace:
SELECT
DATE(cc.consented_at) as date,
COUNT(*) as successful_submissions,
cw.name as clickwrap_name
FROM `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapconsent` cc
JOIN `spotdraft-prod.prod_india_db.public_clickwraps_clickwrap` cw
ON cw.id = cc.clickwrap_id
WHERE cc.tenant_workspace_id = {wsid}
AND cc.consented_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY)
GROUP BY date, cw.name
ORDER BY date DESC
Check clickwrap feature flag for a workspace:
SELECT
cs.type, cs.is_enabled, cs.tenant_workspace_id,
cs.created, cs.modified
FROM `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapsettings` cs
WHERE cs.tenant_workspace_id = {wsid}
Collect from the support ticket or incident channel:
wsid (workspace ID) and cluster (e.g. IN, USA, EU)clickwrap_public_id (UUID from the URL: app.spotdraft.com/clickthrough/{id}/settings)user_identifier, user_email used in the failing submissionCheck GCP logs or Groundcover for server-side error logs during the failure window. If you see:
value_error, none is not an allowed value, str type expected) → payload issue, confirm which fieldCLICKWRAP_FEATURE_FLAG_NOT_ENABLED → Feature flag off for workspace (see Step 5)-- Get clickwrap by public_id
SELECT id, name, public_id, is_deleted, tenant_workspace_id
FROM `spotdraft-prod.prod_india_db.public_clickwraps_clickwrap`
WHERE public_id = '{clickwrap_public_id}'
Check:
is_deleted = FALSE — if deleted, submissions will 404 not 400tenant_workspace_id matches the expected WSIDAsk the customer or check the code snippet they shared. Verify:
user_identifier — Is it possible to be null/empty/undefined for some users?
user.id || user.email || 'anonymous')user_email — Is it validated before sending?
nullfirst_name / last_name — Sent as null?
"" (empty string) as fallback instead of null or omittingagreements array — Populated from SDK init() response?
submit() after the init() promise resolves; confirm agreements[].id and agreements[].version_id come from the init() response, not hardcoded valuesBase URL — Does it have a trailing slash?
https://api.spotdraft.com not https://api.spotdraft.com/If logs show ClickwrapFeatureFlagNotEnabledException, check the feature flag:
SELECT type, is_enabled, tenant_workspace_id
FROM `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapsettings`
WHERE tenant_workspace_id = {wsid}
If the clickwrap feature flag (CLICKWRAP) is disabled → escalate to eng to enable for the workspace.
If the customer says "some succeed, some fail," confirm this by counting consent records:
SELECT DATE(consented_at), COUNT(*) as successes
FROM `spotdraft-prod.prod_india_db.public_clickwraps_clickwrapconsent`
WHERE tenant_workspace_id = {wsid}
AND clickwrap_id = {clickwrap_id}
AND consented_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
GROUP BY 1 ORDER BY 1 DESC
Intermittent successes alongside 400s = strong signal that payload is conditionally invalid (e.g. user_identifier is null for some users but not others).
| Symptom | Likely Root Cause |
|---|---|
| 400 only for some users, others succeed | user_identifier or user_email conditionally null/invalid |
| 400 always, no server-side error log | Request not reaching API (wrong base URL, network issue) |
400 with Pydantic none is not allowed in log | user_identifier sent as null |
| 400 with ForeignKey / IntegrityError | agreements[].version_id invalid or not mapped to clickwrap |
| 400 immediately after deploy | Regression — check if agreements structure changed in SDK update |
400 starts after init() error | submit() called before init() resolved; agreement IDs are undefined |
Send the customer these recommendations (confirmed by engineering in SPD-42278):
user_identifier — Required. Must be a non-null, non-empty string. Use a reliable fallback if the primary identifier may be absent.
user_email — Optional. Validate format before sending. Omit the field entirely if null or invalid — do not send null.
first_name / last_name — Optional. Send empty string "" as fallback instead of null.
additional_custom_information / key_pointer_information — If sending, ensure all values within the dict are valid types; avoid null values inside the object.
Call order — Only call submit() after the init() promise has resolved. The agreements array passed to submit() must come from the init() response.
Base URL — Use https://api.spotdraft.com without trailing slash.
Omit optional fields entirely when they have no valid value, rather than sending null.
The following are known engineering improvements not yet shipped as of the incident date:
user_identifier is a non-empty string in the SDK before making the API call (throw EXECUTION_REQUIRED_FIELD_MISSING or INVALID_USER_IDENTIFIER)execute-clickwrap.usecase before API callbaseUrl (strip trailing slash) during SDK configurationuser_identifier must be non-empty and init() must complete before submit()These are not customer-facing yet — the customer must still implement client-side validation.
Escalate to engineering if:
https://app.spotdraft.com/clickthrough/{clickwrap_short_id}/settings/admin/clickwraps/clickwrapconsent/ (filter by tenant_workspace_id)/admin/clickwraps/clickwrapuser/ (filter by user_identifier)44df08dd-a076-4722-85a5-af15ae4a8259user_identifier, first_name/last_name, and user_email sent as null by customer integration#incident-20260312-medium-centricity-intermittent-400-error-while-submitting-cliccontract_id to verify it was created and its workflow status