Ops Webhooks
Narrow, idempotent POST endpoints for Customer.io workflows and marketing-ops scripts. Add a person to a community; set the signup code on a person.
The ops webhooks are server-to-server write endpoints used by Customer.io workflows and marketing-ops CLI scripts to mutate a small, explicit set of fields on a Person. They are intentionally narrow — one operation per route — so the blast radius is bounded and every call is auditable, idempotent, and rate-limited.
| Operation | Method | Path |
|---|---|---|
| Add a person to a community | POST | /api/v1/ops/people/communities/add |
| Set a person's signup code | POST | /api/v1/ops/people/code |
Base URL
| Environment | API Host |
|---|---|
| Production | app.letsplaymoney.com |
| Development | app.wasim.io |
All ops endpoints live under /api/v1/ops/* on the API host. They are not exposed on the auth host.
The ops webhooks fail closed. If the OPS_WEBHOOKS_ENABLED kill switch is off, every endpoint returns 404 Not Found with no body — clients should treat this as "the feature is disabled" rather than "the endpoint does not exist."
Authentication
Ops webhooks require an OAuth 2.0 client credentials access token bound to a ServiceAccount. Person tokens are rejected — there is no scenario where these endpoints should be called on behalf of a logged-in user.
POST https://{API_HOST}/api/v1/ops/people/communities/add
Authorization: Bearer {SERVICE_ACCOUNT_ACCESS_TOKEN}
Content-Type: application/json
See Authentication → Client Credentials Flow for how to obtain the token.
Two OAuth applications are provisioned for these endpoints:
| Application UID | Caller | Extra requirement |
|---|---|---|
customer_io_service | Customer.io workflows | Must also send the X-CIO-Signature headers |
marketing_ops_service | Internal CLI / Claude-driven scripts | Bearer token only |
Both applications are bound to the marketing-ops-writer role and inherit its permissions (people:add_to_community, people:set_signup_code).
Security Layers
Every request passes through a fixed sequence of fail-closed checks. The order matters — cheap checks run first so that a request that fails one of them never pays the cost of the next.
- Kill switch — if
OPS_WEBHOOKS_ENABLED != "true"the request returns404 Not Found(no body, no clue that the endpoint exists). - IP allowlist — the request's origin IP must match an entry in
OPS_IP_ALLOWLIST(comma-separated CIDRs). Empty allowlist means "deny all". - Bearer token resolution — standard OAuth Bearer validation. Invalid or expired tokens get a
401 Unauthorizedwithcode: "InvalidToken". - Service-account gate — the resolved actor must be a
ServiceAccount. APersontoken gets401 Unauthorizedwith the detail"This endpoint requires a service-account bearer token". - Customer.io signature — when the caller is the
customer_io_serviceapplication, the request must also include validX-CIO-Signature+X-CIO-Timestampheaders (see Customer.io signature verification). Other service accounts skip this check. - Rate limit — at most
OPS_RATE_LIMIT_PER_MINUTErequests per minute per service account (default600). Exceeding it returns429 Too Many Requests. - Permission check — the service account's role must include the permission for the specific action.
Layers 1–4 run on every request. Layer 5 only fires for Customer.io. Layer 6 fires for every authenticated service-account request. Layer 7 fires per action.
Request Format
All ops endpoints expect a JSON:API request body. The top-level shape is:
{
"data": {
"type": "<expectedType>",
"attributes": {
"<camelCaseKey>": "<value>"
}
}
}
data.typemust match the per-endpoint type string (e.g."communityMembership"). A mismatch returns422 Unprocessable Contentwith title"Invalid request".- Attribute keys are camelCase on the wire. The server accepts both
personIdandperson_id(and similar pairs) to tolerate the variety of payload shapes that Customer.io Liquid templates produce. - Unknown attributes are silently dropped by strong-params.
Endpoint — Add to Community
Adds a Person to a Community. Idempotent: if the person is already a member, the response indicates a no-op and no membership row is created.
POST https://{API_HOST}/api/v1/ops/people/communities/add
Required permission: people:add_to_community
Request
{
"data": {
"type": "communityMembership",
"attributes": {
"personId": 42,
"communitySlug": "alumni-2025"
}
}
}
Person identifier — provide exactly one:
| Attribute | Type | Description |
|---|---|---|
personId | integer | Stable Person ID. Preferred. |
email | string | Case-insensitive lookup. Rejected with 409 Conflict if multiple people share the email — use personId instead. |
Community identifier — provide exactly one:
| Attribute | Type | Description |
|---|---|---|
communityId | integer | Community primary key |
communitySlug | string | Human-readable slug (e.g. "alumni-2025") |
Response
{
"data": {
"type": "communityMembership",
"attributes": {
"personId": 42,
"communityId": 7,
"communitySlug": "alumni-2025",
"alreadyMember": false,
"joinedAt": "2026-05-27T18:42:11Z"
}
}
}
| Attribute | Type | Description |
|---|---|---|
personId | integer | Resolved Person ID |
communityId | integer | Resolved Community ID |
communitySlug | string | Community slug |
alreadyMember | boolean | true if the membership already existed and no row was created |
joinedAt | ISO 8601 | null | When the membership was created. null when alreadyMember is true and the original join time is unknown |
Status: 200 OK on both create and no-op.
Possible Errors
| Status | Title | When |
|---|---|---|
422 Unprocessable Content | Invalid request | Missing both communityId and communitySlug, or data.type mismatch |
404 Not Found | Person not found | The supplied personId or email does not match a non-deleted Person |
404 Not Found | Community not found | The supplied communityId or communitySlug does not match a Community |
409 Conflict | Person ambiguous | Multiple non-deleted people share the supplied email |
403 Forbidden | Forbidden | The service account does not hold the people:add_to_community permission |
Endpoint — Set Signup Code
Sets the code field on a Person. The code drives community routing for new-investor onboarding flows.
POST https://{API_HOST}/api/v1/ops/people/code
Required permission: people:set_signup_code
Request
{
"data": {
"type": "personSignupCode",
"attributes": {
"personId": 42,
"code": "VC-Partners"
}
}
}
Person identifier — provide exactly one: personId (preferred) or email. Same rules as Add to Community.
| Attribute | Type | Description |
|---|---|---|
code | string | null | The new signup code. Must be present. Send null (or "") to clear the field back to the platform default. Omitting the key entirely returns 422. |
The code attribute is treated differently from "missing" vs "explicitly null". A request that does not include the code key returns 422 Unprocessable Content. This is intentional — silently clearing a person's code would be a data-loss footgun if a Liquid template variable went missing. To clear the field, send "code": null explicitly.
Response
{
"data": {
"type": "personSignupCode",
"attributes": {
"personId": 42,
"code": "VC-Partners",
"previousCode": "Individual",
"changed": true
}
}
}
| Attribute | Type | Description |
|---|---|---|
personId | integer | Resolved Person ID |
code | string | null | The code after the update |
previousCode | string | null | The code before the update |
changed | boolean | false when the new value matches the existing value (no-op) |
Status: 200 OK on both update and no-op.
Possible Errors
| Status | Title | When |
|---|---|---|
422 Unprocessable Content | Invalid request | code key is missing from attributes, or data.type mismatch |
422 Unprocessable Content | Invalid signup code | code fails validation (too long, contains disallowed characters) |
404 Not Found | Person not found | personId or email does not match a non-deleted Person |
409 Conflict | Person ambiguous | Multiple non-deleted people share the supplied email |
403 Forbidden | Forbidden | The service account does not hold the people:set_signup_code permission |
Idempotency
Both endpoints support an idempotency key sent as either header:
Idempotency-Key: <client-generated-uuid>
X-CIO-Idempotency-Key: <client-generated-uuid>
When supplied:
- The first successful (
2xx) response is cached for 24 hours, scoped to(serviceAccount, path, key). - Subsequent requests with the same key replay the cached response byte-for-byte.
- Replayed responses carry an extra header:
X-Ops-Idempotent-Replay: true. - Non-success responses (
4xx/5xx) are not cached — clients can safely retry.
Both endpoints are also naturally idempotent even without a key: re-adding an existing member is a no-op (alreadyMember: true), and re-setting the same code is a no-op (changed: false). The header just adds wire-level safety against network retries that might re-hit the route.
Customer.io Signature Verification
When the caller is the customer_io_service OAuth application, the request must include both:
| Header | Value |
|---|---|
X-CIO-Timestamp | Unix timestamp (seconds) at the time the request was signed |
X-CIO-Signature | Hex-encoded HMAC-SHA256 of "v0:<timestamp>:<raw-request-body>" keyed by the workspace signing key |
The signing key lives in CUSTOMER_IO_WEBHOOK_SIGNING_KEY on the server and is configured per Customer.io workspace.
Verification rules:
- Both headers are required. Missing either returns
401 Unauthorized. - The timestamp must be within 5 minutes of server time (clock skew + replay protection).
- The signature is compared with constant-time comparison to prevent timing attacks.
- The raw request body is used verbatim — do not minify, re-serialize, or re-order keys before signing.
Other service accounts (e.g. marketing_ops_service) skip this check entirely. They are gated by the Bearer token + permission alone.
Error Response Format
Every error response follows JSON:API errors:
{
"errors": [
{
"status": "422",
"title": "Invalid request",
"code": "InvalidRequest",
"detail": "Missing 'code' attribute (send null to clear)"
}
]
}
| Field | Description |
|---|---|
status | HTTP status code (string) |
title | Human-readable category |
code | Machine-readable error class (only on domain errors raised by the ops services) |
detail | Human-readable specifics — safe to surface to operators |
Domain error codes you may see in the code field:
| Code | Title | Status |
|---|---|---|
InvalidRequest | Invalid request | 422 |
InvalidCode | Invalid signup code | 422 |
PersonNotFound | Person not found | 404 |
CommunityNotFound | Community not found | 404 |
PersonAmbiguous | Person ambiguous | 409 |
Integration Recipes
Customer.io webhook (Liquid)
In Customer.io, configure a webhook action:
- URL:
https://app.letsplaymoney.com/api/v1/ops/people/communities/add - Method:
POST - Headers:
Authorization: Bearer {{env.PLATFORM_OPS_BEARER}} Content-Type: application/json X-CIO-Timestamp: {{ "now" | date: "%s" }} X-CIO-Signature: <generated by CIO from workspace signing key> X-CIO-Idempotency-Key: {{ event.delivery_id }} - Body:
{ "data": { "type": "communityMembership", "attributes": { "personId": {{customer.id}}, "communitySlug": "{{event.community_slug}}" } } }
The bearer token is the access token obtained via the client credentials flow using the customer_io_service client_id / client_secret. Refresh on 401.
curl (marketing-ops CLI)
TOKEN=$(curl -s -X POST "https://auth.letsplaymoney.com/oauth/token" \
-d "grant_type=client_credentials" \
-d "client_id=$MARKETING_OPS_CLIENT_ID" \
-d "client_secret=$MARKETING_OPS_CLIENT_SECRET" \
-d "scope=service" \
| jq -r .access_token)
curl -X POST "https://app.letsplaymoney.com/api/v1/ops/people/code" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"data": {
"type": "personSignupCode",
"attributes": {
"personId": 42,
"code": "VC-Partners"
}
}
}'
The marketing_ops_service account skips the Customer.io signature check, so no X-CIO-* headers are needed.
Auditing & Observability
Every successful mutation writes a row to the audit trail with:
- The subject record (
Person,Community) - The acting service account
- The originating request ID and IP
- The idempotency key (if supplied)
Successful and failed calls are also posted to the OPS_SLACK_CHANNEL Slack channel asynchronously (after the database transaction commits). Slack notifications are best-effort — a failed Slack post never breaks the API response.
Integration Checklist
- Provision the OAuth application (
customer_io_serviceormarketing_ops_service) and store theclient_id/client_secretsecurely - Implement the client credentials token exchange and refresh on
401 - Add the caller's egress IPs to
OPS_IP_ALLOWLIST - For Customer.io: configure the workspace signing key in
CUSTOMER_IO_WEBHOOK_SIGNING_KEYand forwardX-CIO-Signature+X-CIO-Timestampon every webhook - Always send an idempotency key (
X-CIO-Idempotency-Keyfor Customer.io,Idempotency-Keyfor everything else) — use the upstream event's stable delivery ID - Send
code: nullexplicitly to clear a signup code — never omit the key - On
429, back off and retry; on5xx, retry with the same idempotency key
Last updated today
Built with Documentation.AI