> ## Documentation Index
> Fetch the complete documentation index at: https://docs.soundchecklive.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Integrations

> External service integrations in the Soundcheck API

The Soundcheck API integrates with several external services for authentication, payments, messaging, and more.

Inbound data integrations (spreadsheets, coming-from exports, UEF bundles, CheckAI extraction) do not get a connector each. They all land in UEF, and Jev referees the result: see [Jev as the UEF integration layer](https://docs.soundchecklive.io/engineering/backend/jev-uef-integration-layer.md).

## Integration overview

| Service | Purpose | Stub Available |
| - | - | - |
| Clerk | Authentication | N/A (always needed) |
| Stripe | Payments & Subscriptions | ❌ |
| Twilio | SMS Notifications | ✅ |
| SendGrid | Email Notifications | ✅ |
| Google Calendar | Calendar Integration | ✅ |
| Azure Storage | File Storage | ❌ |
| Expo Push | Push Notifications | ✅ |

***

## Clerk (authentication)

Clerk handles user authentication via JWT tokens.

### Configuration

```env theme={null}
CLERK_SECRET_KEY=sk_test_...
CLERK_WEBHOOK_SECRET=whsec_...
```

### Webhook events

| Event | Purpose |
| - | - |
| `user.created` | Upsert user profile |
| `user.updated` | Update user data |
| `organization.created` | Upsert org row |
| `organization.updated` | Update org data |
| `organizationMembership.created` | Add org member |
| `organizationMembership.deleted` | Remove org member |

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](https://docs.soundchecklive.io/engineering/backend/architecture.md).

### Webhook endpoint

```
POST /webhooks/clerk
```

***

## Stripe (payments)

Stripe manages subscriptions, organization payout funding, member Connect accounts, and destination-charge payouts.

### Configuration

```env theme={null}
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
```

### Payment surfaces

| Surface | Purpose |
| - | - |
| Subscription billing | Creates checkout and billing portal sessions for Soundcheck plans |
| Organization Connect | Creates or retrieves the organization's Stripe Connect account |
| Member Connect | Onboards members so they can receive payouts |
| Payout funding methods | Stores the organization's saved funding method for gig payouts |
| Destination-charge payouts | Charges the organization's funding method and transfers the member payout to the member's connected account |

### Gig payout flow

1. The organization has a Stripe account and at least one saved payout funding method.
2. The member completes Stripe Connect onboarding and has a payout destination account.
3. The Owner or a Finance member starts a single ledger payout or event-level batch payout.
4. The API resolves the selected `funding_method_id`, calculates the platform fee for that funding method, and creates a pending transaction.
5. Stripe creates a destination-charge PaymentIntent with metadata for the ledger item and transaction.
6. Webhooks and polling reconcile the transaction to `PAID`, `PROCESSING`, or `FAILED`.

ACH funding methods use a `0%` platform fee because Soundcheck absorbs ACH fees. Card-funded payouts include the configured platform fee in the gross charge amount.

### Webhook events

| Event | Purpose |
| - | - |
| `checkout.session.completed` | New subscription |
| `customer.subscription.updated` | Plan changes |
| `customer.subscription.deleted` | Cancellation |
| `invoice.payment_failed` | Failed payment |
| `payment_intent.succeeded` | Confirm destination-charge payout settlement |
| `payment_intent.payment_failed` | Mark destination-charge payout failure |
| `charge.refunded` | Track Stripe refund state |
| `charge.dispute.created` | Track payout-related disputes |

### Webhook endpoint

```
POST /webhooks/stripe
```

***

## Twilio (SMS)

Twilio sends event invitations and handles invitation replies via SMS.

### Configuration

```env theme={null}
TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=...
TWILIO_FROM_NUMBER=+15551234567
TWILIO_USE_STUB_SMS=true  # Use stub for development
TWILIO_SMS_STATUS_WEBHOOK_URL=https://api.example.com/webhooks/twilio/sms-status  # Delivery status callbacks

# Event group chats (Twilio Conversations) — credentials. Rollout is the
# `event-chats` PostHog flag; chats stay off until these are all set.
# The API Key pair (not the auth token) signs Conversations AccessToken JWTs.
TWILIO_API_KEY_SID=SK...
TWILIO_API_KEY_SECRET=...
TWILIO_CONVERSATIONS_SERVICE_SID=IS...
TWILIO_GROUPCHAT_NUMBERS=+15559990001,+15559990002  # SMS proxy pool (must not overlap TWILIO_FROM_NUMBER)
TWILIO_CONVERSATIONS_WEBHOOK_URL=https://api.example.com/webhooks/twilio/conversations
```

Production Conversations setup (dedicated `IS…` service, pre/post webhooks, 2–4
proxy numbers, auto-creation off, chat-only Messaging Service, A2P 10DLC) is the
[event chats runbook](./event-chats).

### 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-chats` flag

### Webhook endpoints

```
POST /webhooks/twilio
POST /webhooks/twilio/conversations
POST /webhooks/twilio/sms-status
```

Invitation SMS uses `/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 `+1` number 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_numbers` are 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.

  Transient codes such as 30003 and 30008 are not recorded.
* A skipped send returns `skipped` rather 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 are `sms.recipient_skipped` and
  `sms.undeliverable_recorded`.

Twilio reports delivery failures to `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:

```env theme={null}
TWILIO_USE_STUB_SMS=true
```

The stub logs messages to the console instead of sending them.

***

## Cleo Pay

Cleo Pay sends signed bank-account and payable events. Soundcheck verifies `Cleo-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

```env theme={null}
CLEO_ENABLED=false
CLEO_WEBHOOK_SECRET=whsec_...
```

`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

| Event | Purpose |
| - | - |
| `bank_account.created` | Mark the stored account `PROCESSING_VERIFICATION` |
| `bank_account.verified` | Mark the stored account `VERIFIED` |
| `bank_account.verification_failed` | Mark the stored account `UNVERIFIED` |
| `bank_account.removed` | Mark the stored account `REMOVED_BY_EVENT` |
| `payable.paid`, `payable.payment.failed`, `payable.payment.partially_paid`, `payable.payment.reversed`, `payable.cancelled` | Fetch `GET /v1/payables/{id}` and reconcile from Cleo `paymentStatus` plus amounts (do not trust the event type alone) |

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

```
POST /webhooks/cleo
```

A local Cleo → laptop probe needs an ngrok **agent authtoken** (`ngrok http 4000`), not an ngrok API key. See `.agents/skills/ngrok-tunnels/SKILL.md`.

***

## Google calendar

Creates calendar events for confirmed gigs.

### Configuration

```env theme={null}
GOOGLE_CREDENTIALS_FILE=/path/to/credentials.json
GOOGLE_IMPERSONATE_EMAIL=calendar@yourdomain.com
GOOGLE_USE_STUB_CALENDAR=true  # Use stub for development
```

### Setup

**1. Create Google Cloud Project**

1. Go to [Google Cloud Console](https://console.cloud.google.com)
2. Create a new project or select existing
3. Enable the Google Calendar API

**2. Create Service Account**

1. Go to **IAM & Admin → Service Accounts**
2. Create a new service account
3. Download the JSON credentials file

**3. Configure Domain-Wide Delegation**

For G Suite/Workspace accounts:

1. Go to Admin Console → Security → API Controls
2. Add the service account client ID
3. Grant calendar scopes

**4. Add Credentials**

```env theme={null}
GOOGLE_CREDENTIALS_FILE=/app/config/google_credentials.json
GOOGLE_IMPERSONATE_EMAIL=calendar@yourdomain.com
```

### Using stub

```env theme={null}
GOOGLE_USE_STUB_CALENDAR=true
```

The stub logs calendar operations without creating real events.

***

## Azure storage

Stores file attachments for events and songs.

### Configuration

```env theme={null}
AZURE_STORAGE_ACCOUNT=soundcheckfiles
AZURE_STORAGE_KEY=...
AZURE_STORAGE_CONTAINER=attachments
```

### Usage

* Event attachments (stage plots, riders)
* Song attachments (charts, lyrics)
* Profile images

***

## Expo push (notifications)

Sends push notifications to mobile devices.

### Configuration

```env theme={null}
EXPO_PUSH_USE_STUB=true  # Use stub for development
```

### Notification types

* New event invitations
* Invitation reminders
* Event updates
* Chat messages

### Using stub

```env theme={null}
EXPO_PUSH_USE_STUB=true
```

The stub logs notifications without sending them to devices.

***

## 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:

```bash theme={null}
npm run dev:infra
cd api-events && npm run dev:emulator
```

This allows development without:

* Real SMS charges
* Calendar pollution
* Email sending
* Push notification noise

### Testing real services

Override the stub env vars (for example `TWILIO_USE_STUB_SMS=false`) when running `npm run dev:emulator` with real credentials. See [environment variables](https://docs.soundchecklive.io/engineering/reference/environment-variables.md).

***

## 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)`:

| Column | Holds |
| - | - |
| `status` | `CONNECTED` or `DISCONNECTED` |
| `credentials_encrypted` | One AES-256-GCM ciphertext of the provider's secret fields as JSON. Cleared to `NULL` on disconnect. |
| `credential_hints` | Masked hints only, such as `****1234`. Cleared on disconnect. |
| `config` | Non-secret settings, such as the HubSpot portal id or the Luma calendar name |
| `connected_by_user_id`, `connected_at`, `disconnected_at` | Who connected it and when |

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 get `403`; callers outside the org get `404`.

| Route | Does |
| - | - |
| `GET /api/v1/organizations/{orgId}/integrations` | Lists `{ integrations: [...] }` with status, masked hints and config |
| `POST /api/v1/organizations/{orgId}/integrations` | Connects a provider, or replaces its credentials. Body: `provider` plus that provider's fields. |
| `DELETE /api/v1/organizations/{orgId}/integrations/{provider}` | Disconnects and wipes the stored secret. Returns `204`, also when nothing was connected. |

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:

| Route | Does |
| - | - |
| `GET /api/v1/organizations/{orgId}/integrations/luma/events` | Lists upcoming events on the connected calendar, each with `importedEventId` when a gig is already linked. Reads at most 10 pages of 50; `truncated: true` says there were more. |
| `POST /api/v1/organizations/{orgId}/integrations/luma/import` | Body `{ eventIds }`, at most 100 (the web client batches larger selections). Re-reads the events from Luma, then upserts one gig per event. When the listing was truncated, ids past it are read with `GET /v1/events/get?event_id=` and must still be upcoming. Returns `created`, `updated`, `notFound` and `failed`. |

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

Every `external_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:

| Route | Does |
| - | - |
| `GET /api/v1/events/{eventId}/sources/{sourceId}/push-preview` | Reads the event from Luma (`GET /v1/events/get`) and diffs `title`, `schedule` (start, end, zone) and `location` against the gig. |
| `POST /api/v1/events/{eventId}/sources/{sourceId}/push` | Body `{ fields, reviewed_soundcheck, overwrite?, reviewed_source?, notify_guests? }`. Re-reads Luma, re-diffs, and sends the allowed fields with `POST /v1/events/update`. |

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 the `travel.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.

| Route | Does |
| - | - |
| `GET /api/v1/organizations/{orgId}/integrations/brex/status` | `{ connected }`, for gig managers who cannot read the admin-only integrations list. The gig Flights card uses it to show **Import from Brex**. |
| `GET /api/v1/events/{event_id}/brex-flights` | Previews the Brex flights booked for the org's team that depart between two days before the gig and two days after it ends, counted in the gig's time zone. |
| `POST /api/v1/events/{event_id}/brex-flights/import` | Body `{ leg_keys }` from the preview, at most 50. Re-reads Brex, then writes the selected legs to the gig's flights. |

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

1. Add the provider key to `ORG_INTEGRATION_PROVIDERS` and its entry to `ORG_INTEGRATION_PROVIDER_REGISTRY` in `api-events/src/systems/org-integrations/orgIntegrationProviders.ts`: display name, secret `credentialFields`, optional `configFields` (each with a label, maximum length and optional pattern), and the field to mask as `apiKeyMasked`.
2. Add a row to the table-driven cases in `orgIntegrationProviders.test.ts`.
3. If the provider has new body fields, add them to `orgintegrationsv1.ConnectOrgIntegrationRequest` in `orgIntegrationsEndpoints.ts`, then regenerate the spec and clients.
4. On the web, add the provider to `ORG_INTEGRATION_PROVIDERS` in `web/src/lib/schemas/orgIntegrations.ts` and a card entry to `INTEGRATION_INFO` in `web/src/components/settings/IntegrationsSection.tsx`.

No migration is needed: `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:

```env theme={null}
NEW_SERVICE_USE_STUB=true
```

**2. Implement Interface**

Create an interface that both real and stub implementations satisfy:

```go theme={null}
type Notifier interface {
    Send(ctx context.Context, msg Message) error
}
```

**3. Add to Docker Compose**

Set stub as default in `docker-compose.yml`:

```yaml theme={null}
environment:
  - NEW_SERVICE_USE_STUB=true
```

**4. Document Configuration**

Add required variables to `.env.example`

***

## Troubleshooting

> [accordion] SMS not sending in development
>
> Check that the stub is enabled:
>
> ```env theme={null}
> TWILIO_USE_STUB_SMS=true
> ```
>
> If you want real SMS, use the connected profile with real credentials.

> [accordion] Calendar events not appearing
>
> 1. Verify credentials file path
> 2. Check impersonation email is valid
> 3. Ensure domain-wide delegation is configured
> 4. Check stub is disabled: `GOOGLE_USE_STUB_CALENDAR=false`

> [accordion] Webhook signature validation failing
>
> Ensure webhook secrets match between service dashboard and environment variables.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.