Integration overview
Clerk (authentication)
Clerk handles user authentication via JWT tokens.Configuration
Webhook events
Webhooks are delivered asynchronously and may arrive after the user’s first authenticated request.
ProvisionMiddleware handles this race by ensuring all identity rows exist synchronously on every request — see Architecture.
Webhook endpoint
Stripe (payments)
Stripe manages subscriptions, organization payout funding, member Connect accounts, and destination-charge payouts.Configuration
Payment surfaces
Gig payout flow
- The organization has a Stripe account and at least one saved payout funding method.
- The member completes Stripe Connect onboarding and has a payout destination account.
- The Owner or a Finance member starts a single ledger payout or event-level batch payout.
- The API resolves the selected
funding_method_id, calculates the platform fee for that funding method, and creates a pending transaction. - Stripe creates a destination-charge PaymentIntent with metadata for the ledger item and transaction.
- Webhooks and polling reconcile the transaction to
PAID,PROCESSING, orFAILED.
0% platform fee because Soundcheck absorbs ACH fees. Card-funded payouts include the configured platform fee in the gross charge amount.
Webhook events
Webhook endpoint
Twilio (SMS)
Twilio sends event invitations and handles invitation replies via SMS.Configuration
IS… service, pre/post webhooks, 2–4
proxy numbers, auto-creation off, chat-only Messaging Service, A2P 10DLC) is the
event chats runbook.
Features
- Send event invitations
- Handle invitation responses
- Send reminders
- Event group chats (Twilio Conversations): in-app crew chat with SMS-bridged
phone-only members, behind the per-org
event-chatsflag
Webhook endpoints
/webhooks/twilio (TWILIO_WEBHOOK_URL). Conversations
uses /webhooks/twilio/conversations (TWILIO_CONVERSATIONS_WEBHOOK_URL).
Do not point both at the same URL.
Numbers that cannot receive SMS
sendSMS never hands Twilio a number we already know cannot receive a text:
-
A number that is not dialable is skipped before any Twilio call. It must be
E.164, and a
+1number needs 10 digits with an area code and exchange that start with 2-9. Service codes such as 911 are not area codes. -
Numbers in
sms_undeliverable_numbersare skipped. A row is written when a send fails, or when a status callback reports a failure, with one of these codes:- 21211 (invalid number) and 21614 (not a mobile) block the number for good, from every sender, on the first failure.
- 30005 (unknown handset) and 30006 (landline or unreachable carrier) can be
temporary: a phone that is switched off, or a carrier outage. Each failed
send is logged in
sms_delivery_failures, keyed by message SID (a send error has no SID, so each attempt counts once). The number is blocked from every sender only after 3 separate sends fail within 14 days, and the block ends 30 days after the latest failure. - 21610 (the person replied STOP) blocks only the sender they opted out of.
It is also written when an inbound STOP reaches
/webhooks/twilio, and it is cleared when they text START, UNSTOP or YES.
-
A skipped send returns
skippedrather than a failure, so it is never retried. A send error with 30005 or 30006 stays a failure until the number is blocked. The counters aresms.recipient_skippedandsms.undeliverable_recorded.
POST /webhooks/twilio/sms-status, but only
when TWILIO_SMS_STATUS_WEBHOOK_URL is set. In qa and production it must be the
canonical https://…/webhooks/twilio/sms-status URL. Twilio signs the URL we
send as StatusCallback, so the same value is used to check the signature.
Nothing needs changing in the Twilio console.
Using stub
In development, enable the stub to avoid sending real SMS:Cleo Pay
Cleo Pay sends signed bank-account and payable events. Soundcheck verifiesCleo-Signature (HMAC-SHA256 of {t}.{rawBody}, 5-minute skew) and updates stored bank verification status when the funding-source id is already on a bank_accounts row.
Configuration
CLEO_ENABLED follows the same opt-out as QuickBooks: unset means on, and only the literal false disables Cleo. While it is false, register and payment sweeps stay quiet even when a queue exists, and publish paths do not capture a missing-credentials error. Dev, QA, and production set CLEO_ENABLED=false until real credentials are in place. Missing secrets with the flag unset still alarm when queued work exists.
Encore deployments bind the same value as CleoWebhookSecret. On GCP that secret is PLACEHOLDER-seeded and mounted as CLEO_WEBHOOK_SECRET; populate it out of band (gcloud secrets versions add) — it is not copied from Azure Key Vault. An empty or placeholder secret leaves POST /webhooks/cleo inert (500) so Cleo retries the HTTP delivery.
The signing secret is a symmetric credential, not a public key. Cleo documents endpoint secrets as whsec_ tokens with 32 bytes of entropy; api-events treats values that do not resemble that format as unconfigured. Terraform provisions the Secret Manager container and bootstrap placeholder only. Do not put the real value in Terraform source or state.
After a valid signature, Soundcheck publishes the event and returns 200. Only the api-events service account can publish cleo_webhook_received and cleo_payment. Apply (bank-status updates) runs on Pub/Sub (apply-cleo-webhook) so a later database failure does not depend on Cleo resending. Subscription apply failures are captured, then rethrown so Pub/Sub retries. Cleo-lane DLQs have a Cloud Monitoring alert on dead-lettered messages. 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.
Webhook events
Unknown funding-source and payable ids fail the asynchronous apply so Pub/Sub can retry and eventually dead-letter the event; the already-durable HTTP receipt remains acknowledged. Bank-status apply locks the
bank_accounts row, confirms the funding-source id still matches, then compares the signed sequence (numeric, must be greater than the stored cursor) and otherwise Cleo createdAt so an older or concurrent event cannot rewind verification. Replacing a funding-source id clears that cursor. Subscription apply uses the provider timestamp only — not the receipt clock. Dedup uses the signed payload id only; Cleo-Event-Id is outside the HMAC and is ignored.
Payable reconcile after GET: require the GET id to match the requested payable, integer nonnegative cents, and total = paid + remaining. Direct-send settle only when GET is settled and provider totals exactly match the local instruction. Unpaid failed / unpaid cancelled mark the pair FAILED only when total === local; otherwise set cleo_reconciliation_needed and alarm. A split failed with amountPaidCents > 0 stays PROCESSING, sets cleo_reconciliation_needed, and alarms. A terminal GET whose totals do not match the frozen instruction also sets cleo_reconciliation_needed and alarms — it does not fall through to “remains processing.” Reverse only when GET is clawback (payable.payment.reversed + GET still settled retries). Full clawback requires total === local && paid === local && remaining === 0 and uses one DB transaction for succeed-then-reverse. Conserved paid === 0 && remaining === local under clawback is unconfirmed Cleo counter semantics and goes to reconciliation, never FAILED. Successful terminal writes clear cleo_reconciliation_needed. After settle, GET clawback inserts a new REVERSED ACH_TRANSFER (reverses_transaction_id) and returns the item PAID → UNPAID — the original SUCCEEDED row is never mutated. Cleo does not send contact.* webhooks in the staging catalog; member contact readiness is reconciled with GET /v1/contacts/{id} on the contact-register sweep and again before payout create. HTTP bank/contact status patches skip overwrite when a webhook cursor is already stored, except authoritative GET FAILED / VERIFIED, and refuse regressing VERIFIED / REMOVED* / FAILED / REMOVAL_* back to PROCESSING_VERIFICATION / UNVERIFIED.
Queued ACH payments persist destination + Cleo ids on the transaction. A definite unpublished cleo.payment.requested fails the pair; an ambiguous broker acknowledgement stays PENDING so the processor can still claim and the sweep can republish. Pub/Sub is a wake-up; the processor claims a 60s submit lease (ownership token) and the payment sweep only selects rows with those instruction columns (legacy/manual ACH is never aged out). Submitted PENDING rows with a cleo_payment_id are GET-reconciled after ~2 minutes. With the 5-minute cron that window is 2–7 minutes: still-processing payables are left alone until 5 minutes, then flagged; only a real terminal GET is settled. A terminal payable webhook whose GET is still processing is retried. Deleting a bank account while a PENDING ACH references it as source or destination returns 409. After in-flight payments settle, delete marks REMOVAL_PENDING, drops the row from owner lists and first-default counts, and revokes via Cleo DELETE /v1/bank-accounts/{id} (org) or POST /v1/contacts/{id}/archive (member). Provider 404 is success. If Cleo is unconfigured and a provider id exists, revoke stays REMOVAL_PENDING and alarms. Delayed create/verified webhooks cannot leave REMOVAL_* / REMOVED. Org microdeposit verify GETs first, claims the pair before POST, and only marks VERIFIED from Cleo status verified (microDepositsCompletedAt is readiness, not proof). Permanent 4xx clears the stored deposit pair. Unchanged GET attempts while the 60s verify lease is still active stay quiet. After the lease expires, reconciliation is set only when token + inflight pair + funding-source + lifecycle still match. PATCH with retry_verification: true is org-only: it clears the expired recon flag, authorizes one later resubmit if GET attempts are unchanged, keeps claim evidence, and republishes register so GET can consume an advanced attempt, submit once, or restore reconciliation. Member PATCH rejects the field. Submit persist and permanent fail require the current claim token and cleo_payment_id IS NULL; a lost CAS re-reads and flags reconciliation instead of overwriting another worker. Age-out uses the same unsubmitted + expired-claim predicate. Retry-registration clears FAILED only before a Cleo provider id exists and when owner plus observed ids still match; otherwise 409. A vanished funding source during verify is marked FAILED. Legacy banks are offered only after cleo_offered_at is set; they are not aged out on first Cleo enable. Every Cleo split transfer id is stored for webhook lookup.
Webhook endpoint
ngrok http 4000), not an ngrok API key. See .agents/skills/ngrok-tunnels/SKILL.md.
Google calendar
Creates calendar events for confirmed gigs.Configuration
Setup
1
Create Google Cloud Project
- Go to Google Cloud Console
- Create a new project or select existing
- Enable the Google Calendar API
2
Create Service Account
- Go to IAM & Admin → Service Accounts
- Create a new service account
- Download the JSON credentials file
3
Configure Domain-Wide Delegation
For G Suite/Workspace accounts:
- Go to Admin Console → Security → API Controls
- Add the service account client ID
- Grant calendar scopes
4
Add Credentials
Using stub
Azure storage
Stores file attachments for events and songs.Configuration
Usage
- Event attachments (stage plots, riders)
- Song attachments (charts, lyrics)
- Profile images
Expo push (notifications)
Sends push notifications to mobile devices.Configuration
Notification types
- New event invitations
- Invitation reminders
- Event updates
- Chat messages
Using stub
Development workflow
Default: emulated or stubbed
In local api-events development, external services are emulated or stubbed by default. Clerk and Cleo Pay use local emulators, while the remaining integrations use in-process stubs:- Real SMS charges
- Calendar pollution
- Email sending
- Push notification noise
Testing real services
Override the stub env vars (for exampleTWILIO_USE_STUB_SMS=false) when running npm run dev:emulator with real credentials. See environment variables.
Per-org API-key integrations
Some integrations belong to one organization rather than the platform: the org pastes its own HubSpot or Flex API key in Settings → Integrations. These share one credential store and three routes, so a new provider is a registry entry, not a new table or endpoint.Storage
organization_integrations (Prisma OrganizationIntegration) holds one row per (organization_id, provider):
Encryption uses the shared helper in
api-events/src/systems/bank-accounts/encryption.ts with CREDENTIAL_ENCRYPTION_KEY, the same key and nonce || ciphertext || tag format as QuickBooks tokens. The associated data is organization_integration:v1:{orgId}:{provider}, so a ciphertext copied to another org or provider does not decrypt. Outside local development and tests, a missing key fails closed: connect answers 503 SERVICE_UNAVAILABLE and nothing is stored. No read returns a secret.
The table is not captured by the audit trigger. Connect and disconnect write integration_connected and integration_disconnected to the org activity log, with the provider name only.
Routes
All three require an org owner or admin. Other members get403; callers outside the org get 404.
Server code that needs a key calls
readOrgIntegrationCredentials(orgId, provider) in systems/org-integrations/orgIntegrationsOperations.ts. It returns null when the provider is not connected.
Key checks on connect
verifyOrgIntegrationCredentials in orgIntegrationVerification.ts runs before anything is encrypted. A provider that can test a key does it there and may add non-secret config to store. Luma calls GET /v1/calendars/get: a 401 or 403 answers 400 INVALID_CREDENTIALS, any other failure answers 503, and in both cases nothing is stored. A good key stores the calendar name as calendarName. HubSpot and Flex are saved without a check.
Luma
An admin pastes a Luma calendar API key. The Luma API needs Luma Plus. Two routes read the saved key:
Both need an org owner or admin, and answer
409 when Luma is not connected or no longer accepts the key. Each gig is linked by an external_event_identities row with provider LUMA, so importing an event again updates its gig instead of making a copy. A Luma event finds its gig only through that identity (matchByIdentityOnly), never by the title-and-date fallback other importers use, so two same-name events on one day stay two gigs and an unrelated gig is never taken over. Each event’s upsert holds a transaction advisory lock on luma:{orgId}:{lumaEventId}, so two imports running at once cannot both create a gig. A new gig starts as DRAFT, and an existing gig keeps its status. The upsert is the shared upsertEvent, so a date or time someone set by hand is not moved. The gig date is the civil day of the Luma start in the event’s zone. On re-import it is the civil day in the zone the gig keeps (eventDateFollowsStoredZone), because the shared upsert never replaces a stored zone, so the date and zone cannot disagree. Import writes import_committed with source: luma. The client is api-events/src/clients/lumaClient.ts. LUMA_API_BASE_URL points it at a local emulator in development only.
Event sources and write-back
Everyexternal_event_identities row on a gig is a source. The event payload returns them as event.sources (id, provider, provider_object, external_id, display label, url, can_push, last_synced_at, last_pushed_at, and last_push_error after a failed push). external_links is still derived from the same rows for HubSpot and Flex. Callers who cannot manage gigs get sources: [] (redactContactDetails). URL sources take their label from the page host (sourceNameForHost). The code is in api-events/src/systems/event-sources/ and api-events/src/db/eventSources.ts.
Only Luma sources can be pushed to:
Both need gig-manager access (
gig.manage) and use the org’s saved Luma key. Each field is same, changed, conflict or unavailable. The baseline is what Soundcheck last saw in Luma, stored as metadata.source_baseline on the identity row. Luma import writes it, and every push writes it again after reading the event back. A field is changed when the gig differs from Luma and Luma still equals the baseline. It is conflict when Luma moved since the baseline, or when the baseline has no entry for that field (gigs imported before this existed). The baseline is per field: after a push, a field moves to what Luma holds only when the read-back matches the gig. A conflict the caller did not push stays a conflict, and so does a pushed field someone changed in Luma again before the read-back. End times are compared only when both sides have one. A gig with no end time pushes its start with Luma’s existing length, so the new start never lands after Luma’s end. A push refuses a conflict field unless the caller lists it in overwrite and sends the Luma value it reviewed in reviewed_source (the preview row’s source). The overwrite applies only while Luma still holds that value; otherwise the push answers 409 SOURCE_CONFLICT with a fresh preview in data. Every pushed field also needs the gig value the caller reviewed in reviewed_soundcheck (the preview row’s soundcheck). The push sends a field only while the Luma update built from the current gig equals the one built from that reviewed value, so a gig edit made after the preview is never sent unseen; otherwise the push answers 409 SOURCE_PREVIEW_STALE with a fresh preview. A field the gig has no value for is unavailable and never sent, so a push cannot blank a Luma field. A place sends { type: 'google', place_id } when the gig has a place id, otherwise { type: 'manual', address }. suppress_email is the inverse of notify_guests, which defaults to false.
The push is a synchronous user action and is not retried. A Luma failure stores last_status = FAILED and last_error on the row and maps to 409 INVALID_CREDENTIALS, 404 SOURCE_GONE, 422 SOURCE_REJECTED or 503. If the database write after a successful Luma update fails, the push still answers 200. The baseline is then stale, so the next preview shows the pushed fields as conflicts, which fails closed. A success writes last_outbound_at, the new baseline, and the event_source_pushed audit action. The Luma emulator implements /v1/events/update for local runs.
Brex
An admin pastes a Brex user token. A Brex account admin creates it under Developer → Settings with thetravel.trips.readonly scope, plus users.readonly so employees can be matched by email. Connect checks the token with GET /v1/trips?limit=1. Brex has no travel webhooks, so Soundcheck reads trips when a gig manager asks. Brex expires a user token after 90 days without a call, and the gig routes then answer 409 INVALID_CREDENTIALS asking an admin to reconnect.
Both gig routes need gig-manager access and answer
409 when Brex is not connected or no longer accepts the token. A traveler matches an active org member only by email: a guest’s guest_email, or an employee’s email from Brex GET /v2/users/{id}. A match needs exactly one active member in the gig’s org with that email. A shared email, an unknown email, or no email at all leaves the traveler unmatched, and names are never used to match, so a booking cannot land on a member who shares a name or sits in another org. Anyone who does not match is listed in unmatched_travelers and is never imported. Brex bookings are per traveler, so the preview merges segments with the same airline, flight number and departure into one leg with several passengers. A leg already on the gig, matched by flight number and departure instant, gets the new passengers rather than a copy. Passengers upsert on (flight, user), so importing again adds nothing twice. Cancelled, refunded, voided and draft trips, bookings and journeys are skipped. Writes for one gig run in one transaction under an advisory lock on brex-flights:{orgId}:{eventId}, so a failed import leaves nothing behind. Legs and crew go in as batched multi-row inserts, so a 50-leg import makes a fixed number of statements and stays inside Prisma’s five-second transaction limit. The web client sends at most 50 legs per request and batches larger selections. Each import records import_committed with source: brex on the gig. Hotels from Brex are not imported yet, because a gig stay needs a hotel directory record. The client is api-events/src/clients/brexClient.ts. BREX_API_BASE_URL points it at the local emulator on :4124 in development only.
Adding a provider
- Add the provider key to
ORG_INTEGRATION_PROVIDERSand its entry toORG_INTEGRATION_PROVIDER_REGISTRYinapi-events/src/systems/org-integrations/orgIntegrationProviders.ts: display name, secretcredentialFields, optionalconfigFields(each with a label, maximum length and optional pattern), and the field to mask asapiKeyMasked. - Add a row to the table-driven cases in
orgIntegrationProviders.test.ts. - If the provider has new body fields, add them to
orgintegrationsv1.ConnectOrgIntegrationRequestinorgIntegrationsEndpoints.ts, then regenerate the spec and clients. - On the web, add the provider to
ORG_INTEGRATION_PROVIDERSinweb/src/lib/schemas/orgIntegrations.tsand a card entry toINTEGRATION_INFOinweb/src/components/settings/IntegrationsSection.tsx.
provider is free text that only the registry validates. Rows for a provider that the registry no longer lists are skipped by the list route.
Adding new integrations
When adding a new external service:1
Create Stub Option
Add environment variable for stub mode:
2
Implement Interface
Create an interface that both real and stub implementations satisfy:
3
Add to Docker Compose
Set stub as default in
docker-compose.yml:4
Document Configuration
Add required variables to
.env.exampleTroubleshooting
SMS not sending in development
SMS not sending in development
Check that the stub is enabled:If you want real SMS, use the connected profile with real credentials.
Calendar events not appearing
Calendar events not appearing
- Verify credentials file path
- Check impersonation email is valid
- Ensure domain-wide delegation is configured
- Check stub is disabled:
GOOGLE_USE_STUB_CALENDAR=false
Webhook signature validation failing
Webhook signature validation failing
Ensure webhook secrets match between service dashboard and environment variables.