logo
Ops WebhooksOverview & Endpoints

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.

OperationMethodPath
Add a person to a communityPOST/api/v1/ops/people/communities/add
Set a person's signup codePOST/api/v1/ops/people/code

Base URL

EnvironmentAPI Host
Productionapp.letsplaymoney.com
Developmentapp.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 UIDCallerExtra requirement
customer_io_serviceCustomer.io workflowsMust also send the X-CIO-Signature headers
marketing_ops_serviceInternal CLI / Claude-driven scriptsBearer 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.

  1. Kill switch — if OPS_WEBHOOKS_ENABLED != "true" the request returns 404 Not Found (no body, no clue that the endpoint exists).
  2. IP allowlist — the request's origin IP must match an entry in OPS_IP_ALLOWLIST (comma-separated CIDRs). Empty allowlist means "deny all".
  3. Bearer token resolution — standard OAuth Bearer validation. Invalid or expired tokens get a 401 Unauthorized with code: "InvalidToken".
  4. Service-account gate — the resolved actor must be a ServiceAccount. A Person token gets 401 Unauthorized with the detail "This endpoint requires a service-account bearer token".
  5. Customer.io signature — when the caller is the customer_io_service application, the request must also include valid X-CIO-Signature + X-CIO-Timestamp headers (see Customer.io signature verification). Other service accounts skip this check.
  6. Rate limit — at most OPS_RATE_LIMIT_PER_MINUTE requests per minute per service account (default 600). Exceeding it returns 429 Too Many Requests.
  7. 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.type must match the per-endpoint type string (e.g. "communityMembership"). A mismatch returns 422 Unprocessable Content with title "Invalid request".
  • Attribute keys are camelCase on the wire. The server accepts both personId and person_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:

AttributeTypeDescription
personIdintegerStable Person ID. Preferred.
emailstringCase-insensitive lookup. Rejected with 409 Conflict if multiple people share the email — use personId instead.

Community identifier — provide exactly one:

AttributeTypeDescription
communityIdintegerCommunity primary key
communitySlugstringHuman-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"
    }
  }
}
AttributeTypeDescription
personIdintegerResolved Person ID
communityIdintegerResolved Community ID
communitySlugstringCommunity slug
alreadyMemberbooleantrue if the membership already existed and no row was created
joinedAtISO 8601 | nullWhen 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

StatusTitleWhen
422 Unprocessable ContentInvalid requestMissing both communityId and communitySlug, or data.type mismatch
404 Not FoundPerson not foundThe supplied personId or email does not match a non-deleted Person
404 Not FoundCommunity not foundThe supplied communityId or communitySlug does not match a Community
409 ConflictPerson ambiguousMultiple non-deleted people share the supplied email
403 ForbiddenForbiddenThe 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.

AttributeTypeDescription
codestring | nullThe 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
    }
  }
}
AttributeTypeDescription
personIdintegerResolved Person ID
codestring | nullThe code after the update
previousCodestring | nullThe code before the update
changedbooleanfalse when the new value matches the existing value (no-op)

Status: 200 OK on both update and no-op.

Possible Errors

StatusTitleWhen
422 Unprocessable ContentInvalid requestcode key is missing from attributes, or data.type mismatch
422 Unprocessable ContentInvalid signup codecode fails validation (too long, contains disallowed characters)
404 Not FoundPerson not foundpersonId or email does not match a non-deleted Person
409 ConflictPerson ambiguousMultiple non-deleted people share the supplied email
403 ForbiddenForbiddenThe 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:

HeaderValue
X-CIO-TimestampUnix timestamp (seconds) at the time the request was signed
X-CIO-SignatureHex-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)"
    }
  ]
}
FieldDescription
statusHTTP status code (string)
titleHuman-readable category
codeMachine-readable error class (only on domain errors raised by the ops services)
detailHuman-readable specifics — safe to surface to operators

Domain error codes you may see in the code field:

CodeTitleStatus
InvalidRequestInvalid request422
InvalidCodeInvalid signup code422
PersonNotFoundPerson not found404
CommunityNotFoundCommunity not found404
PersonAmbiguousPerson ambiguous409

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_service or marketing_ops_service) and store the client_id / client_secret securely
  • 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_KEY and forward X-CIO-Signature + X-CIO-Timestamp on every webhook
  • Always send an idempotency key (X-CIO-Idempotency-Key for Customer.io, Idempotency-Key for everything else) — use the upstream event's stable delivery ID
  • Send code: null explicitly to clear a signup code — never omit the key
  • On 429, back off and retry; on 5xx, retry with the same idempotency key