api-events/. Values are read in
api-events/src/config/config.ts (Encore secret(...) with env fallbacks). Use
this page for smoke tests and incident diagnosis.
Local provider emulators
Every outbound provider has a local emulator built on emulate (emulate@0.12.0, pinned
under the npm release-age policy). Custom providers are defineEmulator
definitions with JSON-only state:
tools/emulators/— Stripe, SendGrid, Resend, Brevo, QuickBooks, Dropbox, Google Calendar, Expo push, Supermemory, context.dev, n8n, PostHog, Luma, Apple Music, the web-only Google Maps and Spotify, plus emulate’s built-in Slack and Google (Drive). Ports4101-4125; the Compose service isemulators. Seetools/emulators/README.mdfor the port and env table.tools/twilio-emulator/(:4013) andtools/cleo-pay-emulator/(:4092) — same framework, own packages and existing contracts.tools/clerk-emulator/(:4711) — still the vendored upstream emulator. Switching to emulate’s Clerk must preserve our persona manifest, persistent signing keys, and Svix-signed webhooks.
STRIPE_API_BASE_URL,
SENDGRID_API_BASE_URL, QUICKBOOKS_*_BASE_URL, DROPBOX_API_BASE_URL,
GOOGLE_DRIVE_API_BASE_URL, GOOGLE_CALENDAR_API_BASE_URL,
EXPO_PUSH_API_BASE_URL, SLACK_API_BASE_URL, HUBSPOT_API_BASE_URL,
FLEX_API_BASE_URL) go through
localEmulatorBaseUrl() in config.ts: loopback or the emulators Compose
host only, ignored in QA and production. The older provider URL settings the
emulators also use (RESEND_BASE_URL, BREVO_BASE_URL,
SUPERMEMORY_BASE_URL, PRICING_CONTEXTDEV_BASE_URL, N8N_*_WEBHOOK_URL,
POSTHOG_HOST, POSTHOG_QUERY_HOST) are honored in every environment, so
never set them to an emulator outside local dev and CI. Stub flags still win
when set. The emulators are opt-in for dev:emulator; set the env listed in
the README to route a provider to them.
Contract tests (*.emulator.test.ts beside each client) run the real client
against its emulator in-process and are part of the normal unit suite. The
checks job also type-checks tools/emulators and boots every provider
(npm run smoke).
Known gaps: Twilio’s emulator does not cover Conversations SDK WebSockets;
built-in Slack has no apps.uninstall; built-in Google accepts any bearer and
has no Docs export. HubSpot and Flex have no API emulator because api-events
only builds links to them (Flex quotes go through the n8n webhook).
Organization sending domains (SendGrid)
Pro-and-above orgs authenticate their own domain at SendGrid and send fromlocal_part@their-domain instead of the platform address. The system lives in
api-events/src/systems/sending-domains/, provider HTTP in
api-events/src/clients/sendGridDomainAuthClient.ts, persistence in
api-events/src/db/organizationSendingDomains.ts.
Statuses are
PENDING → VERIFIED | FAILED, plus DISCONNECTED once
removed. Every check stores a human summary of the invalid records in
last_error and stamps last_checked_at.
A failing check does not immediately mean FAILED. SendGrid returns the
same valid: false for “the records are wrong” and “the records are published
but have not propagated yet”, so a domain stays PENDING for 48 hours after it
was added and only then starts reporting FAILED — an admin who re-checks a
minute after editing their zone file sees “waiting”, not “action needed”. The
two exceptions are a domain that was already VERIFIED (a regression is never
propagation lag, so it fails at once) and a row whose created_at cannot be
parsed (fails closed). The audit outcome metadata distinguishes all three:
pending | failed | verified.
If SendGrid answers the validate call with a 404 — the authenticated domain was
deleted out from under us — the check does not report a provider outage. It
marks the row FAILED with last_error “This domain no longer exists at the
email provider. Remove it and add it again.” and returns 200, so the admin gets
an instruction instead of a retry loop. There is no scheduled re-check in V1 —
verification is the manual button only.
Every endpoint requires org ADMIN/OWNER. Add, verify, default-from and
test-email additionally require the sending-domains entitlement (Pro and above) and answer
403 PLAN_UPGRADE_REQUIRED (not a 404) without it, so the settings page can
show an upgrade CTA. The list and remove endpoints deliberately do not check
the entitlement: an org that drops below Pro while holding a domain must still
be able to see it and disconnect it, otherwise the row keeps the global domain
claim and the SendGrid resource forever. The list response carries
entitled: boolean so the page can render a downgraded org in remove-only
mode. Error codes: INVALID_DOMAIN,
DOMAIN_ALREADY_ADDED, DOMAIN_CLAIMED, DOMAIN_NOT_VERIFIED,
DEFAULT_FROM_REQUIRED, CALLER_EMAIL_REQUIRED, PROVIDER_ERROR,
NOT_FOUND.
The add flow is row-first, and the row is never thrown away. The row is
inserted PENDING with a null provider_domain_id before SendGrid is called,
so the claim on the domain is taken under the unique index rather than by a
provider round trip, and the 409 conflicts are decided with no provider call
at all. A provider-less row means “needs provisioning”, and
provisionPlaceholder is the single path that fills it, from both add and
re-check: look the domain up by name at SendGrid
(GET /v3/whitelabel/domains?domain=), adopt it if it is already there,
create it only if it is genuinely absent, then attach the id and records under a
guarded write (provider_domain_id IS NULL AND status IN ('PENDING','FAILED'),
which also resets the row to PENDING and clears last_error).
The lookup-before-create is what makes the ambiguous case safe: a request that
timed out after SendGrid accepted the create leaves a real authenticated
domain behind, and a naive retry would create a second one and orphan the first.
Adoption requires proof of ownership. SendGrid authenticated domains live in
one shared account, so an exact name match proves nothing — a Pro org could
otherwise adopt a domain someone else authenticated (manually, or through
another integration) and reach VERIFIED without ever publishing DNS. Every
object we create therefore carries a per-row identity derived from the row uuid:
subdomain = sc + the first 10 hex digits, and custom_dkim_selector = 3
alphanumerics from the same digits, so two rows on one root domain never collide
on s1._domainkey.<domain>. Nothing is stored for this; it is recomputed from
row.id. The lookup returns every exact-name match and we adopt only the one
whose subdomain equals ours. Other matches are left strictly alone — never
adopted, never deleted.
That identity also removes the deleted-org handover race: an object queued for
release belonged to a different row and therefore carries a different subdomain,
so it can never be adopted by whoever claims the domain next. Before creating we
do eagerly release any queue entry for the same domain, so a rejected create
(SendGrid refusing a duplicate) does not leave the next org waiting on an
unrelated drain.
The drain carries the mirror-image guard: before deleting at SendGrid it
re-reads organization_sending_domains for an active row referencing that
provider id, and if one exists it drops the queue entry untouched and counts it
superseded. That is defensive — guard one should make it unreachable — but it
also covers handles attached by any other route, including manual repair.
A provider failure during provisioning never deletes the row. The row stays
PENDING with last_error “Provider setup did not complete. Re-check to retry.”
and a fresh last_checked_at, and the endpoint returns 502. That keeps the
domain claimed by the org that asked for it — releasing the claim on a transient
outage would let another organization take the domain out from under them — and
makes re-check the retry button. Re-adding the same domain in the same org
reuses the stalled row; another org still gets DOMAIN_CLAIMED. sending_domain_added
is audited only on the first successful attach; a re-provision from verify
audits sending_domain_checked.
Organization deletion. The organization_id cascade would destroy the only
copy of provider_domain_id, so deletion enqueues the cleanup instead of
performing it. Inside the delete transaction — in deleteOrganizationOnClient,
so both the org-settings endpoint and the Clerk organization.deleted webhook
get it — one sending_domain_release_queue row is written per live domain that
holds a provider handle, immediately before the cascade. That table carries no
foreign key to Organization for the same reason
the twilio_conversation_deletion_v2 effect in effect_outbox does not: the
queue has to outlive the organization it describes. A deletion that never commits enqueues nothing.
Removal works the same way round. DELETE disconnects the row and enqueues
its provider id in one transaction, then returns 200 — it never calls SendGrid
on the request path, so a provider outage can no longer leave an active row
pointing at a deleted resource, and the response does not depend on the provider
being reachable. A single targeted release runs eagerly afterwards as a latency
optimisation only; the queue is what guarantees the delete.
Draining is a bounded pass (drainSendingDomainReleaseQueue): a SendGrid delete
(404 counts as success) removes the queue row, an entry whose provider id a live
row has since claimed is dropped without calling SendGrid and counted
superseded, and any failure increments attempts, records last_error and
leaves the row for the next pass. It never throws into its caller.
The scheduled job sendingDomainReleaseDrain runs it every 15 minutes (limit
25, 60s time budget) under the shared job lock, with the usual private
expose: false manual endpoint for ops. Request paths do not await a drain:
organization deletion (both the settings endpoint and the Clerk webhook) and the
add / verify operations publish to the drain topic after commit, the same shape
as publishTwilioConversationDeletePrompts. The only eager drains left are the
two single-entry, specifically-targeted ones above (pre-create and post-remove),
which stay best effort.
Watch the depth of sending_domain_release_queue and its attempts column —
broker backlog is not the same as unfinished work. PRO-449 now covers only the
verification re-check sweep.
Clerk verified-domain mirror (PRO-450). When a check takes a domain to
VERIFIED, the same request best-effort mirrors it onto the org’s Clerk
Organization Domains via ensureVerifiedOrganizationDomain: an existing entry
with that name (case-insensitively) is flipped verified: true keeping its
enrollment mode, and a missing one is created automatic_suggestion +
verified: true. Success stores clerk_domain_id and clerk_domain_synced_at
on the row under a status = 'VERIFIED' guard; failure is reported through
captureServiceError(…, 'clerk', 'EnsureVerifiedOrganizationDomain', …) and the
verify still returns 200 VERIFIED — DNS proof is the product promise, the
Clerk mirror is a convenience. The sending_domain_verified audit metadata
carries clerk_domain: created | verified | already_verified | failed.
A re-check also runs the mirror when a row is already VERIFIED but
clerk_domain_synced_at is null, so domains verified before this shipped — or
during a Clerk outage — catch up on the next re-check rather than needing a
backfill. clerk_domain_synced_at therefore has no column default: stamping
every existing row would claim a sync that never happened and permanently
disable that catch-up.
SENDING_DOMAIN_CLERK_SYNC=false switches the mirror off for a Clerk instance
that has Verified Domains disabled; SENDING_DOMAIN_CLERK_SYNC_USE_STUB, and
the notification dry run, stub it.
Removal deliberately does not touch Clerk. Disconnecting a sending domain
says nothing about whether people with an address there should still be able to
join the org — that is enrollment, and it belongs to the Staff page’s Team
Domain panel. Revoking it here would silently change who can sign up.
Audit triggers. organization_sending_domains and
sending_domain_release_queue are classified in audit.table_config with
enabled = true and carry no capture trigger, which is the intended state
rather than an oversight: per 20260825210000_audit_trigger_floor, enabled
means “MAY be audited”, attachment is a deliberate named wave, and 102 of the
107 classified tables sit exactly here. Only wave 5a (bank_accounts,
OrgMember, Organization, Subscription, white_label_config) has ever
attached. Every mutation on a sending domain already writes a semantic
recordAudit row, which is the customer-facing surface; adding these tables to
the trigger floor is a separate write-volume decision for whoever runs the next
wave.
The API key itself is never logged; provider errors carry the domain, the
provider domain id, and the HTTP status only.
provider_domain_id is not
exposed on the wire and is deny-listed in auditClassification.ts.
Org correspondence sends from the org’s verified identity. Each org-context
sender wraps its params in withOrgSender(params, orgId)
(systems/sending-domains/orgEmail.ts). It sets from and replyTo to
resolveOrgSendingIdentity(orgId) and leaves the platform sender in place when
the resolver returns null. The resolver returns null when any of these
fails: the domain is VERIFIED, it has a From address, the org has a Pro+
entitlement, and the org-email-domains PostHog flag is on for the org. It
caches per org for 60 s (per instance), and the verify / From / remove
operations invalidate that entry.
Wired senders:
- availability requests
- crew offers (including reschedule
.ics) - gig invitations (including
.ics) - event cancellation
- direct notifications (bulk, broadcast and template)
- the lead-request confirmation (reply-to prefers the org contact email)
- Clerk org team invites
- emails to the org’s own staff (crew responses, new-lead alerts)
- Clerk-sent auth mail
- CheckAI
Cleo Pay
CLEO_ENABLED=false blanks the API key and webhook secret. Register and payment sweeps then return without listing the queue or capturing a missing-credentials error, and the publish paths stay quiet. Messages already on the register and payment subscriptions ack without calling Cleo or marking the row failed, so a rollout does not retry them into the DLQ. Those rows stay pending for the sweep once the flag is turned back on. Dev, QA, and production set that value until real credentials are in place. Missing secrets with the flag unset still alarm when queued work exists.
GCP Secret Manager provisions CleoWebhookSecret (mounted as CLEO_WEBHOOK_SECRET). It is not copied from Azure Key Vault — after Terraform apply, replace the PLACEHOLDER version with gcloud secrets versions add.
Cleo-lane Pub/Sub DLQs (cleo_payment, cleo_bank_register, cleo_contact_register, cleo_webhook_received), Brevo lane DLQs (brevo_signup_contact, brevo_tracked_event), scheduled-maintenance DLQs, and the crew-notification fan-out DLQs (availability request/SMS/email/push/push-target/request-response, invitation send/response, crew-offer send/response) page Slack via Cloud Monitoring through the same channel list. qa/prod must pass cleo_dlq_alert_notification_channel_ids — the same OOB Slack channel resource names operators attach to platform-signals pipeline alerts. Dev may stay empty. Do not invent channel IDs; copy them from Cloud Monitoring after the Slack OAuth channel exists. Terraform apply remains Cloud-Agent-only. An empty channel list still creates the policies; they just do not page. Replay is a manual pull from <env>-<subscription>-dlq and republish to the source topic — there is no Slack consumer on DLQ topics and no auto-replay.
POST /webhooks/cleo verifies Cleo-Signature, publishes to cleo-webhook-received, then returns 200. Only the api-events service account can publish that topic. Bank-status apply runs eagerly after the ack and again on subscription apply-cleo-webhook (apply failures are captured, then Pub/Sub retries). Signed bank events (bank_account.created / verified / verification_failed / removed) lock the bank_accounts row and update cleo_bank_account_status when cleo_funding_source_id still matches and the event is newer than the stored Cleo cursor (cleo_last_event_sequence, then cleo_last_event_at). Replacing a funding-source id clears that cursor. Subscription ordering uses Cleo createdAt/sequence only, not receipt time. Stale or equal events are acknowledged without rewriting status. HTTP create/verify must not overwrite that webhook cursor or regress a terminal status. Payable events (payable.paid / payable.payment.failed / partially_paid / reversed) always GET /v1/payables/{id} before a terminal write: fully settled succeeds the locked pair, unpaid failed fails it, in-flight or split-fail stays PROCESSING, and clawback after PAID inserts a REVERSED ACH row. Member contacts have no Cleo webhook in staging — the contact-register cron GETs registered contacts and remaps archived / revoked to REMOVED. Bind topic dev|qa|prod.integration.cleo_webhook_received.v1 before relying on the async path in a deployed environment.
A local public probe (Cleo dashboard → laptop) is Encore on :4000 plus an ngrok tunnel. Use the ngrok agent authtoken (NGROK_AUTHTOKEN), not the ngrok API key — leftover files named ngrok.authtoken are often the API key and fail with ERR_NGROK_107. See .agents/skills/ngrok-tunnels/SKILL.md and pitfall 17. Sign a fresh body that includes id; Cleo signatures expire after five minutes and cover {t}.{rawBody} only.
Client forms
Client forms are native: questions, rendering and responses live in Soundcheck (form_definitions, form_revisions, form_assignments, form_submissions),
so they need no provider keys, webhooks or secrets. Each assignment’s direct
link carries its form_assignments.share_token and is answered through
/api/v1/public/form-links/*. The token is minted on first use, by the link
email or by one of the two share-link routes (staff and holder); see
Forms as a channel.
n8n
Integrations deep links (same as historical Go):
HUBSPOT_APP_BASE_URL,
HUBSPOT_PORTAL_ID, FLEX_BASE_URL via integrationsDeepLinkConfig().
App file storage
api-events uses GCS whenGCS_BUCKET is set and Azure Blob Storage otherwise (App file storage).
Azure Storage (until decommission)
AI Gateway and event ingestion
Model calls (ingest analysis, multi-file merge, import column mapping) go through Vercel AI Gateway using its Anthropic-compatible Messages endpoint. Model ids use the gateway namespace (anthropic/claude-sonnet-4.6);
legacy direct ids (claude-sonnet-4-6) are rewritten on the way out.
AI-merge dependency notes live in
api-events/src/systems/event-ingestions/AI_MERGE_DEPS.md.
Mobile update-policy store verification (ENG-57)
Each env name is also an Encore
secret(...) (GooglePlayPackageName,
GooglePlayServiceAccountJson, AppStoreConnectKeyId,
AppStoreConnectIssuerId, AppStoreConnectPrivateKey, AppStoreConnectAppId)
used when the env var is unset; PLACEHOLDER/empty reads as unconfigured, not
a boot failure. GCP Secret Manager provisions them only when the environment
sets enable_google_play_store_secrets / enable_app_store_connect_secrets
(infra-gcp events module). Neither has an Azure Key Vault source — after
apply, populate with gcloud secrets versions add (Play Console service
account + package name; App Store Connect API key, issuer, and app id), then
roll the Cloud Run revision.
Operator-triggered publication (bootstrap)
Publication is intentionally manual:POST /internal/mobile-update-policy/publish
publishes store-verified update rules. Scoped disable and the global service-console
switch can advance pointers to empty snapshots; reading a new scope while the
switch is OFF initializes an empty snapshot at revision 1. Scheduled reconciliation
only refreshes releases already referenced by a published policy. Keep enforcement
OFF until acceptance is complete, then use the authorized service-console workflow
to permit fresh publication.
Runbook (
scripts/publish-mobile-update-policy.mjs, dry-run unless --confirm):
First read the current scope through the authenticated
GET /api/v2/organizations/{organizationId}/mobile/update-policy endpoint using
the same platform/channel and installed version/build parameters. Set
CURRENT_SNAPSHOT_REVISION to the returned data.snapshotRevision. An empty
policy does not imply revision 0: an OFF read starts at revision 1. Revision 0
is valid only if the scope has never initialized a snapshot. Inspect current
state after a conflict before preparing a new command; do not automatically
replace a stale expected revision.
gcloud auth print-identity-token --impersonate-service-account=$MOBILE_UPDATE_PUBLISH_CALLER_SA --audiences=<audience> --include-email
(no secret on argv; a pre-minted token may be supplied via
MOBILE_UPDATE_PUBLISH_ID_TOKEN). The operator principal needs
roles/iam.serviceAccountTokenCreator on the caller service account. The
trusted actor is the verified caller email — the body cannot assert an actor.
Failure/optimistic-concurrency behavior (exit codes in the script header):
conflict (409) means the expected revision is stale or the idempotency key
was reused with a different payload — re-read current scope state and retry
with a fresh key for a changed command, the SAME key to replay. not-verified
(422) means no fresh AVAILABLE store observation covers the release/territory
audience — run evidenceMode=verify-fresh (the default) so the platform store
adapter is checked at publish time, or wait for a reconciliation observation
younger than 60 minutes and use evidenceMode=persisted. unconfigured (503)
means the store credentials above are not deployed for that platform.
Resend contact sync
Hourly full recompute of global Resend Contacts (0 * * * * in
api-events/src/systems/resend-contacts/). The job is read-only against
Postgres and writes outbound HTTP only. Merge does not turn it on.
Enablement after merge:
- Apply
infra-gcpso Cloud Run mountsResendAPIKeyandPostHogQueryAPIKey(PLACEHOLDER until populated) and soPOSTHOG_PROJECT_ID=276289is set. This apply creates{env}-PostHogQueryAPIKeyand removes{env}-PostHogPersonalAPIKey. - Create matching Contact Property keys in the Resend dashboard. A rename here without a dashboard key is a silent no-op.
- Populate
ResendAPIKeyand{env}-PostHogQueryAPIKeywithgcloud secrets versions add(not Azure KV, notPOSTHOG_API_KEY). Use a dedicated PostHog user scoped to query/read on project276289. - Stub run:
RESEND_CONTACT_SYNC_ENABLED=truewith dry-run still on. Confirm phase timings and the 28 property keys. - Small live slice, then prod.
is_internal prefers PostHog cohort 410365. If that HogQL cohort query fails, the job logs a warning and falls back to @soundchecklive.io plus INTERNAL_EMAIL_ALLOWLIST in posthogQueryClient.ts. PostHog query timeout, a missing results array, or an empty person-property result set omits PostHog-derived keys for that run so a transient or empty HogQL response cannot overwrite existing Resend values; Clerk and Postgres writes still proceed. Lifecycle does not mark dormant from Clerk sign-in alone when PostHog is unavailable, so a transient query failure cannot overwrite an active contact as dormant. A failed Postgres fact query aborts the run before any Resend writes so a transient DB error cannot empty org, plan, or lifecycle properties.
INCOMPLETE subscriptions are excluded from the live-subscription join, so those orgs get empty plan, status, and seats. Capacity assumes a worst-case two Resend requests per contact (PATCH then POST). Consecutive hourly runs advance the roster by that capacity so slices do not overlap. Raising RESEND_MAX_REQUESTS_PER_SECOND covers the full roster in one run without a code change.
Brevo signals
Marketing lifecycle email runs out of Brevo. api-events owns two write paths into it: a contact upsert at signup and nine tracked events that enter and exit Brevo automations (api-events/src/systems/brevo-signals/). Both go through
api-events/src/clients/brevoClient.ts. Tracked events have their own switch
(BREVO_TRACKED_EVENTS_ENABLED), so they can be turned off without stopping the
signup sync. Prod sets both switches true and sends tracked events to the live
Brevo account. Dev and qa set both true with BREVO_USE_STUB, so stub writes
are logged and nothing reaches Brevo.
Signup contact
The Clerkuser.created webhook publishes a BrevoSignupContactRequested
message to Pub/Sub (brevo-signup-contact) after the durable user steps
succeed; a publish failure fails the webhook so Clerk redelivers. The
upsert-brevo-signup-contact subscription owns the Brevo call: it upserts
keyed on EXT_ID (Clerk user id) with updateEnabled, so a redelivery updates
rather than duplicates, and it throws on a Brevo failure so Pub/Sub retries
(5 attempts, then the DLQ). Attribute names are the uppercase Brevo contract
in brevoAttributes.ts; Brevo silently drops unknown keys, so a typo produces
an empty field rather than an error. Booleans are JSON true / false: the
Brevo UI shows them as a Yes/No dropdown, but the API silently discards the
strings "Yes" / "No" (every Sep 23 sign-up landed without IS_PAYING until
this was fixed). Dates are YYYY-MM-DD in UTC.
The webhook reads the web sign-up wizard’s answers from Clerk
unsafe_metadata.onboarding. When that object exists (the buyer path), the
upsert adds USER_TYPE=buyer, plus TEAM_SIZE and GIG_FREQUENCY when their
values are in the Brevo category enums (any other value is dropped, because
Brevo rejects it silently). A sign-up without the wizard (mobile crew, invite
acceptance) sends no USER_TYPE. The crew events set it later.
Consent lists 7, 8 and 9 (BREVO_SIGNUP_CONSENT_LIST_IDS) and IS_PAYING=false
go only on a create. The client first posts /contacts without
updateEnabled, with listIds. If Brevo answers 400 duplicate_parameter (the
contact already exists: marketing import, earlier crew_invited, or a retry),
it falls back to the plain updateEnabled upsert without lists or
IS_PAYING. An existing contact is never re-subscribed, and never marked
unpaid by a sign-up. Crew-invite contacts never get lists. A person invited
before they sign up therefore stays off the lists until marketing adds them.
Tracked events
Contact properties ride along through
contact_properties: EXT_ID,
ORG_ID, ORG_NAME, USER_TYPE=buyer on every event addressed to the org
primary admin; EVENTS_BOOKED_COUNT, FIRST_GIG_DATE, LAST_GIG_DATE on
event_created and the computed events; IS_PAYING (active or past-due
subscription not cancelling at period end) on every org-addressed event,
computed from the live subscription state when the event is sent (not when it
is recorded), so a late delivery never writes a stale value; IS_PAYING=true, PLAN_TIER, MRR on
subscription_started; IS_PAYING on subscription_cancelled (false unless
another live paid plan remains). The crew events carry USER_TYPE=crew,
FIRSTNAME / LASTNAME (the roster name in that org, then the name on the
invitation) and EXT_ID when known. USER_TYPE=crew is skipped for anyone
who is an active OWNER or ADMIN of any org (matched by user id or email), so a
buyer is never downgraded. crew_invited and crew_profile_completed never
carry SIGNUP_DATE: an invite is not a sign-up. crew_invited also carries
INVITER_NAME (the gig creator’s or Clerk inviter’s roster name, then
username, then org name). crew_invite_accepted fills in the contact the
invite created: EXT_ID, FIRSTNAME / LASTNAME and SIGNUP_DATE from the
Clerk account (names fall back to the roster), plus ORG_ID, ORG_NAME and
LIFECYCLE_STAGE=crew_active unless the person is already a buyer, whose own
org and lifecycle stay untouched. Its event properties carry invitation_id,
invite_source and inviter_user_id when Clerk supplies one.
The two org-invitation signals have no database fact to reconcile from, so the
Clerk webhook is their durable trigger: a ledger write failure returns 500 and
Svix redelivers. organizationInvitation.accepted looks the person up in Clerk
by email (it can arrive before user.created) and answers 503 until Clerk
returns them. Brevo’s “wait until event” step misses an event that fires before
the contact reaches the wait, which is why acceptance also sets
LIFECYCLE_STAGE=crew_active: an attribute the automation can check at any
time. MRR is the plan price × seats per month
(annual ÷ 12), computed with decimal.js. LAST_GIG_DATE is the latest
eventDate of a non-cancelled gig. Active win-back means the org’s latest
subscription_cancelled is newer than its latest subscription_started and
it is not paying now. The computed events only consider orgs that already
have a ledger row, so pre-launch orgs join the lifecycle when they next create
a gig rather than all at once.
Durable owner. Every signal is a row in brevo_signal_emissions (the
ledger and the outbox). dedupe_key is unique and encodes the arming period,
so an entry event is recorded at most once per period and an exit event once
per occurrence; id is the UUID v5 of the key and is the Pub/Sub event id and
inbox key. Producers insert with ON CONFLICT DO NOTHING.
- Request paths (gig create, invitation send, onboarding completion) record
best-effort after their own write commits: a ledger failure is captured to
Sentry and never fails the user’s request. The hourly
brevo-signal-reconcilejob is the recovery owner: it rescans the committed domain rows from the last three days (never beforeBREVO_SIGNALS_EPOCH_ISO, 2026-09-23) and records anything a request path missed, including creation paths with no hook. - The Stripe webhook records synchronously and fails closed (500, Stripe redelivers) if the ledger write fails, like the Clerk signup publish.
brevo-signal-relay(every 5 min) publishes unpublished rows tobrevo-tracked-eventand stampspublished_atonly after Pub/Sub accepts.track-brevo-eventchecks thebrevo_tracked_eventinbox, then takes a 90s lease (brevo_tracked_event_claim) before loading the row. Brevo events have no idempotency key, so a delivery that finds the lease held throws and retries later instead of posting a second copy. If Brevo accepted the event but the terminal write failed, the lease is sealed, and the next delivery finishes the write without posting again. Each send is aborted after 60s, so the lease only lapses once the previous attempt is over (finished, aborted, or its worker died). A delivery that reclaims a lapsed lease sends again, which errs toward a duplicate over a lost event. The processor loads the row, posts/v3/events, then stampsdelivered_atand seals the inbox in one transaction. A Brevo 400/404/422 is a permanent rejection (rejected_at, sealed, captured). Anything else throws so Pub/Sub retries 5 times, then dead-letters. If the kill switch is off when a message arrives, the row’spublished_atis cleared so the relay resends it once the switch is back on.brevo-lifecycle-signalsruns daily at 15:00 UTC and records the computed events.
BREVO_TRACKED_EVENTS_ENABLED on after a period off does not replay
the gap: the reconcile window is max(BREVO_SIGNALS_EPOCH_ISO, now − 3 days),
so the first run records direct signals for domain rows from the last three
days only (gigs created, invitations sent, onboardings completed, org setups,
subscription changes). subscription_started additionally needs the
subscription itself to be created on or after the epoch. The computed buyer
events start from orgs that have a ledger row, so they begin with those
recent orgs. Anything older than three days at enable time is not sent.
Brevo has no idempotency key on /v3/events, so the inbox narrows but cannot
close the duplicate window: a crash after Brevo accepts and before the inbox
commit, or two overlapping deliveries of one message, can repeat an event.
The client does not retry a 5xx on /events for that reason.
Useful checks: unfinished work is
SELECT signal, count(*) FROM brevo_signal_emissions WHERE delivered_at IS NULL AND rejected_at IS NULL GROUP BY 1;
a relay backlog is rows with published_at IS NULL older than ten minutes.
Entry/exit coverage audit. org_setup_completed (entry, once per org)
and event_created (exit, every gig) gate the buyer sequences. Run
api-events/scripts/brevo/audit-signal-coverage.sql. It is read-only and wrapped
in BEGIN TRANSACTION READ ONLY … ROLLBACK:
since as
recorded_once, DUPLICATE, missing_no_owner_or_admin (expected: nobody
to address), pending_next_reconcile (under 65 minutes old),
MISSING_outside_reconcile_lookback, or MISSING. It lists the unrecorded
ids and reports ledger delivery state per signal. A healthy result has no
DUPLICATE, MISSING, or stuck rows. The real-Postgres suite
brevoSignalReliability.integration.test.ts pins both guarantees. It checks
re-saves, overlapping sweeps, and a late admin for org_setup_completed, and
450 gigs across batches plus the inline path for event_created.
DLQ replay
Every lane’s DLQ topic has a Terraform-managed pull subscription named<env>-<subscription>-dlq (7-day retention, never expires, no dead-letter
policy of its own, output dlq_subscription_names). It only holds messages
dead-lettered after it was created; older ones are reachable through the DLQ
topic’s 14-day retention with a temporary subscription and seek.
A dead-lettered signup message is the only durable copy of that signal. A
dead-lettered tracked event still has its ledger row (
published_at set,
delivered_at and rejected_at NULL). On a page, fix the cause first (wrong
or expired key, Authorized IPs, an attribute name that no longer exists),
then:
CloudPubSubDeadLetter*
attributes Pub/Sub adds on dead-lettering; the processors read nothing but
the JSON body. Pull without --auto-ack and ack only after every publish
succeeds; unacked messages reappear after the 60-second ack deadline. Replay
is safe: both processors dedupe on event_id, the signup upsert is
idempotent on EXT_ID, and a tracked event already delivered is sealed
without calling Brevo. For a tracked event whose DLQ copy has aged out,
UPDATE brevo_signal_emissions SET published_at = NULL WHERE id = '<id>' AND delivered_at IS NULL AND rejected_at IS NULL
hands it back to the relay.
The client retries a 429 on every call. It retries a 5xx only for contact
upserts: a tracked event has no idempotency key, and a duplicate re-enters a
Brevo automation, so an ambiguous 5xx on /events surfaces to the caller.
Enablement after merge:
- Apply
infra-gcpso Cloud Run mountsBrevoAPIKey(PLACEHOLDER until populated) and themarketing.brevo_signup_contact.v1andmarketing.brevo_tracked_event.v1topics, subscriptions, DLQs, DLQ pull subscriptions, and the threemaintenance.brevo-*scheduled jobs exist. Thebrevo_signal_emissionsmigration applies on the next api-events boot. - Populate
{env}-BrevoAPIKeywithgcloud secrets versions addusing thesoundcheck-app-prodkey from the Brevo account. - Brevo’s Authorized IPs: either keep the feature off, or allowlist the environment’s Cloud NAT egress address. Prod egress is pinned through Cloud NAT; read it from the Terraform output
cloud_run_nat_ip(terraform -chdir=infra-gcp/environments/prod output cloud_run_nat_ip). A blocked IP surfaces as a 401. - Stub run:
BREVO_SIGNALS_ENABLED=trueandBREVO_TRACKED_EVENTS_ENABLED=true(set it inextra_cloud_run_envbesideBREVO_SIGNALS_ENABLED) with dry-run still on. Confirm theSTUB BREVO CONTACT UPSERTlog line on a test signup andSTUB BREVO EVENTlines after creating a gig (within five minutes, once the relay runs). - Live on dev with one test signup and one gig. Check the contact in Brevo carries
EXT_ID,SIGNUP_DATE, and names, and thatorg_setup_completed/event_createdappear on its activity; then qa, then prod.
Resend webhook
Subscribes to Resend email lifecycle events (
delivered, clicked, bounced,
complained, suppressed, failed). The handler verifies the Svix signature,
publishes a ResendEmailLifecycleEvent to Pub/Sub (resend-email-lifecycle),
and marks ingress dedupe (resend_webhook) only after publish succeeds. A
subscription processor (capture-resend-email-analytics) claims with a 20s
lease (not the default 5m) so a crashed in-flight owner is reclaimable before
five Pub/Sub deliveries exhaust, then awaits PostHog capture and marks
resend_email_analytics on success. Capture is subscription-only so a
killed request cannot hold the inbox claim after 200. Programmatic sends
should attach identity tags (clerk_user_id, org_id) so captures can resolve
distinct_id. Missing identity tags still ack 200 but skip analytics.
GCP Secret Manager provisions ResendWebhookSecret (mounted as
RESEND_WEBHOOK_SECRET). It is not copied from Azure Key Vault — after
Terraform apply, replace the PLACEHOLDER version with gcloud secrets versions add.
Cutover (register the Resend webhook URL and event types) is tracked in ENG-570.
Stripe webhook
Clerk
Portal Clerk public_metadata binding
Portal invitations bind the contact and org via Clerk
public_metadata:
portal_contact_idportal_organization_id
/api/v1/portal/**. Portal scope is resolved
server-side from that metadata — never from the request body or query.
Caller auth
Clerk session JWTs are the only caller auth.ENABLE_BASIC_AUTH=true fails
closed. E2e fixtures mint emulator session tokens so JWT sub matches
Postgres User.id.
/api/v1/admin/** is parked and 404s every authenticated request. Optional
WHITE_LABEL_ENFORCE_ENTITLEMENT=true still surfaces on entitlement bundles
if that console is revived later.
Plan entitlements
entitlements/registry.json says which plan tier each paid feature needs;
src/generated/entitlements.ts is generated from it. Check a feature with
requireEntitlement(orgId, key) from src/systems/entitlements/entitlements.ts.
It resolves the org’s tier from the newest active, trialing or past-due
subscription, then a live comp, and caches it for 10 seconds (at most 5,000 orgs).
- A key marked
enforcedanswers403 PLAN_UPGRADE_REQUIREDbelow its tier. - A key marked
shadowlets the call through. It logsentitlement: would denyand sendsentitlement_rejectedto PostHog at most once an hour per org and key. ENTITLEMENTS_ENFORCE=offturns every key into shadow mode.GET /api/v1/organizations/:orgId/entitlementsreturns the tier, seat limits, and each key’sallowedandenforcedstate to any org member.
entitlementMiddleware, from the route table in
src/systems/entitlements/entitlementRoutes.ts (first match wins). Every route
starts in shadow mode, whatever its key’s enforcement says, because web, mobile
and MCP surfaces that were never plan-gated call the same routes. A route blocks
only once its rule sets enforce: true, and then only for org members: a
non-member falls through to the handler’s own 403 so the org’s plan is not
revealed. Crew self-service routes (offers, availability requests, own
availability, /onboarding/me), Assets, data exports, disconnects, OAuth
callbacks and status reads are deliberately left out; entitlementRoutes.test.ts
lists them. Each member MCP tool call checks mcp-access for the org its input
names, falling back to the token’s active org. Tools that take only a resource id
(such as an event id) are attributed to the token’s org, so move the check into the
operation-level org authorization before enforcing mcp-access.
A row in organization_entitlement_grants gives one org an entitlement outside
its tier (grandfathering, a sales deal, a support exception); plan checks and
the entitlements endpoint count it. Staff manage grants with
scripts/entitlements/grants.ts. Before switching a key with a
grandfatherFromTier to enforced, run --grandfather <key> --before <ISO> (dry run) and
then add --apply. --before is when the key moved up a tier. Only orgs created before it,
that held grandfatherFromTier or above at --before (per the audited subscription and
comp history) and are still on it, get a grant. A cutoff older than the audit history is
refused.
White-label and the dedicated e-sign identity still pass unless
WHITE_LABEL_ENFORCE_ENTITLEMENT=true.
Twilio and event chats
Launch steps: event chats runbook.
Platform admin (service console)
Soundcheck staff identity is a Clerk userpublic_metadata flag, not an org
role and not the parked X-Staff-Actor allowlist:
- Grant or revoke: set
public_metadata.platformAdmintotrueorfalseon the Clerk user. Users cannot write this themselves. A platform admin can do it from/service-consolefor one existing Clerk user whose email domain is exactlysoundchecklive.io. The Dashboard and Backend API remain available. - Session token: include user public metadata on the JWT. Clerk’s default
claim name is
metadata; the web middleware also acceptspublic_metadata. - Web:
/service-consoleis gated by that flag. Orgadmin/owneris not enough. Local emulator: Avery Owner is seeded with the flag. - API: authorize with
isPlatformAdminFromClaimson Clerk-verified session claims (api-events/src/auth/platformAdmin.ts).isPlatformAdminFromJwtonly decodes an already-verified token and is not an authentication gate. Do not reintroduce Basic Auth.
Public URLs
Bank-account encryption
Admin bank-account routes stay available even when storage is disabled.
Org funding for ACH transfer paths expects a Cleo-VERIFIED funding account
with a Cleo funding-source id. The member must have a Cleo-ready contact.
Queueing a direct deposit persists an immutable ACH instruction on the
transaction (source, destination, Cleo contact, and funding-source ids) and
publishes
cleo.payment.requested as a wake-up. The processor claims a 60s
submit lease and creates the Cleo payment from those stored fields only.
Cleo sweeps require the frozen instruction columns so they cannot fail
legacy/manual ACH. A failed publish fails the PENDING/PROCESSING pair so the
payable is not left in flight. Settlement to PAID is still webhook-driven and
GET-authoritative; a terminal webhook whose GET is still processing is retried.
Bank-account delete returns 409 while any in-flight ACH references the row,
then marks REMOVAL_PENDING and revokes the Cleo resource. Owner lists exclude
removed/removal rows.
Feature-flag gated routes (server-side PostHog evaluation)
api-events evaluates PostHog feature flags server-side (src/systems/shared/featureFlags.ts,
posthog-node, remote evaluation, 60 s cache, fail-closed). A gated route or job uses the same
flag key the web uses, so rollout lives in one place. Do not add per-org env allowlists.
crew-offer-unclaimed-dispatch replaces the retired CREW_OFFER_UNCLAIMED_DISPATCH_ORGS
allowlist and is evaluated per organization from the dispatchers (no caller). It is off everywhere
by default: an org can import every freelancer it ever hired, and those people never opted in, so
one “send to all” would become hundreds of first-contact emails from a domain that never mailed
them. Target an org only once its first-contact template and unsubscribe are in place. SMS to
unclaimed recipients stays suppressed regardless of the flag (TCPA exposure is worse than CAN-SPAM).
Gig workbook export (West Coast Music)
GET /api/v1/organizations/:orgId/events/:eventId/wcm-workbook.xlsx fills the
customer’s own Excel binder (13 tabs, curated template at
api-events/src/systems/gig-workbook/templates/) from a gig. It is a
per-customer surface, gated by one PostHog flag on both sides:
Callers need the gig-manager role; prices, deposits, overtime rate and payroll
cells are written only for callers with financial clearance. The template
recomputes its contract, invoice and details tabs on open (
fullCalcOnLoad);
re-curate it with scripts/gig-workbook/curateWcmTemplate.ts when the
customer sends a new master copy.
Apple Music song import
Mobile and web search the Apple Music catalog, or paste a song, album or playlist link, to create library songs. The system lives inapi-events/src/systems/music/; provider HTTP and developer-token signing are in api-events/src/clients/appleMusicClient.ts. Imports are metadata only: Apple Music audio is DRM-protected and is never downloaded or stored. Responses carry title, artist, album, duration, ISRC, artwork and the catalog link for the picker; a song created from a track keeps its title, artist and duration, with the catalog link as a media link. ISRC, artwork and ids are display and duplicate-check data and are not persisted.
Both routes need the caller to manage gigs in the organization (
gig.manage), the same bar as creating songs. Neither writes anything: the client creates songs through the existing POST /api/v1/songs. Links are parsed, never fetched; only https://music.apple.com and https://embed.music.apple.com paths are accepted and only the storefront and catalog id are used.
Configuration is three secrets, all from one Apple Developer MusicKit key: APPLE_MUSIC_TEAM_ID, APPLE_MUSIC_KEY_ID and APPLE_MUSIC_PRIVATE_KEY (the full .p8 file; literal \n sequences are accepted). Terraform: enable_apple_music_secrets creates the three PLACEHOLDER-seeded secrets (infra-gcp/README.md). There is no rollout flag: with any of the three unset or still PLACEHOLDER, both routes answer 503 MUSIC_PROVIDER_UNAVAILABLE and nothing leaves the service. The client signs an ES256 developer token (raw 64-byte signature, one hour lifetime) and reuses it until five minutes before expiry. A rejected token or an Apple outage also answers 503 and is reported to Sentry as music.search or music.resolve; Apple’s 429 answers 503 MUSIC_PROVIDER_BUSY without an alert. APPLE_MUSIC_API_BASE_URL points the client at the emulator (loopback or emulators only, ignored in QA and production).
Spotify and SoundCloud search
Each extra catalog has its own client inapi-events/src/clients/ (spotifyClient.ts, soundCloudClient.ts, sharing musicProviderHttp.ts) and joins /providers only once its secrets hold real values. Terraform: enable_music_search_secrets creates SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SOUNDCLOUD_CLIENT_ID and SOUNDCLOUD_CLIENT_SECRET, PLACEHOLDER-seeded. A catalog without credentials answers 503 MUSIC_PROVIDER_UNAVAILABLE and nothing leaves the service.
- Spotify: client-credentials token (cached until a minute before expiry, refreshed once on a 401),
/v1/search?type=track. Pasted Spotify track and album links (andspotify:URIs) resolve through the same client. - SoundCloud: OAuth 2.1 client-credentials token from
secure.soundcloud.com,/tracks?q=limited to playable and preview tracks. Needs an approved SoundCloud API app.
503 MUSIC_PROVIDER_BUSY without an alert; other provider failures answer 503 and report to Sentry with the provider name. SPOTIFY_API_BASE_URL, SPOTIFY_ACCOUNTS_BASE_URL, SOUNDCLOUD_API_BASE_URL and SOUNDCLOUD_AUTH_BASE_URL point a client at an emulator locally (ignored in QA and production).
Search, resolve and match results carry library_song_id when the organization’s song library already has the song. The check compares title and artist ignoring case, accents and punctuation, so a live or remastered version is a different song; a library song with no artist matches any artist. The library’s song titles (at most 10,000) are read once per request.
Smoke test after populating the secrets: GET /api/v1/organizations/{orgId}/music/search?q=dreams as a gig manager returns tracks with duration_seconds; the same call as a crew member returns 403.
Forwarded confirmation intake (MAR-39)
The signed Inbound Parse endpoint isPOST /webhooks/forwarded-email. It is separate from outbound organization sending identities and the conversational CheckAI email channel. Sending-domain verification CNAMEs and Clerk team-domain enrollment do not provision this receiving host or authenticate a forwarded message.
Configure FORWARDED_EMAIL_ENABLED=true, FORWARDED_EMAIL_DOMAIN (a dedicated inbound subdomain with SendGrid MX/Parse routing), and INBOUND_EMAIL_VERIFICATION_KEY or Encore secret InboundEmailVerificationKey (the Parse signature public key). Enable signed parsed multipart delivery, not raw MIME mode. The settings address is hidden until all three configuration values are present. Keep the existing ingestion-recovery scheduled job and structured AI provider configured; it owns retry and source cleanup. The feature defaults off. The self-hosted dev, qa, and prod infra files declare an empty InboundEmailVerificationKey fallback so disabled deployments can build without provisioning a receiving key. Set INBOUND_EMAIL_VERIFICATION_KEY at runtime before enabling forwarding; the environment value takes precedence over that empty fallback.
The recipient alias selects the organization. Aligned sender authentication and exactly one active member are required for extraction; a verified organizational sending domain never bypasses either check. Disconnecting or changing the organization’s From domain leaves the inbound alias unchanged. No sender replies are generated by this workflow.
Acknowledgement follows durable receipt storage. Sources expire after 30 days; imported/discarded history and reviewed proposals remain. General audit snapshots exclude the source ledger to avoid duplicating private email content. Deployment verification must include one signed member forward, one held unknown sender, an identical redelivery, manager approval, and inspection of the resulting native gig records.
Source cleanup continues while forwarding is disabled. Review responses include source text and attachment filenames; attachment bytes remain available only to extraction. Each extraction has an eight-minute deadline within a ten-minute claim lease, and a stale completion cannot publish a proposal.
A later confirmation updates matching flight legs. A new route for an existing passenger booking requires resolving the itinerary on the gig before import; a fresh multi-leg booking can be approved together. Contract corrections replace their marked section in admin notes and preserve surrounding notes. If that section has been removed or its markers changed, approval stops for manual resolution.
Non-flight confirmations match by organization, record type, and case-insensitive booking reference. Changing a title, passenger detail, record order, or selected gig cannot create a second mapping for that reference; selecting another gig requires resolving the existing booking first. Records without a confirmation reference remain specific to their receipt and row, since there is no reliable identity for matching separate emails. Repeated references of the same type within one review are rejected as ambiguous.
CSV extraction uses a preview of the header and at most 200 data rows, 40 columns, and 256 characters per cell. CSV attachments share a 64,000-byte preview budget plus filename and truncation notices. Reviewers should consult the original attachment when a preview omits information.
CSV parsing scans at most the first 128 KiB and stops after 201 complete records, retaining quoted multiline cells while discarding an incomplete trailing record at the byte limit. PDF text conversion is attempted before binary analysis; binary PDFs are limited to 20 MiB and 100 pages across the request, images to 5 MiB each, and combined binary attachments to 20 MiB. Unsupported sizes stop with an actionable review error without automatic model retries.
Flight legs are shared by airline, flight number, route, and departure instant. Passenger confirmation codes are stored on the passenger association, so separate bookings for the same scheduled flight do not create duplicate legs.