External Services
This page documents every third-party service used by the platform: its purpose, the environment variables it requires, and operational considerations.
Stripe
Purpose: Payment processing for photo package purchases.
Used by: main-api
Keys
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY | Server-side secret key (sk_live_... or sk_test_...) |
STRIPE_WEBHOOK_SECRET | Signing secret for webhook event verification (whsec_...) |
How it works
- A user selects a package —
POST /v1/purchasescreates a StripePaymentIntentand returns itsclient_secret. - The browser completes payment with Stripe.js.
- Stripe sends a
payment_intent.succeededwebhook tomain-api. - The webhook handler (verified with
STRIPE_WEBHOOK_SECRET) setspurchase.status = 'paid'and enqueuesnotify_purchase_confirmed.
Considerations
- Use
sk_test_...+ Stripe CLI webhook forwarding in development (stripe listen --forward-to localhost:3002/v1/stripe/webhook). - The webhook endpoint must be reachable by Stripe (use ngrok or similar in local dev).
STRIPE_WEBHOOK_SECRETdiffers between test and production environments — regenerate it in the Stripe dashboard for each environment.- Never log the full PaymentIntent or card details.
Resend
Purpose: Transactional email (invites, purchase confirmations, password resets).
Used by: main-api
Keys
| Variable | Description |
|---|---|
RESEND_API_KEY | Resend API key (re_...) |
Emails sent
| Job | Trigger |
|---|---|
notify_user_invited | Operator sends bulk invites via backoffice |
notify_purchase_confirmed | Stripe webhook confirms a payment |
notify_password_reset_requested | User requests a password reset |
notify_user_tagged | A face tag is confirmed in a photo |
Considerations
- All emails are sent asynchronously via BullMQ workers — the request that triggers them returns immediately.
- The
notify_user_invitedworker usesMAIN_WEB_URL(media-api env) to build invite links. - In development, point
RESEND_API_KEYat a test API key and use a Resend sandbox domain to avoid sending real emails.
Cloudflare R2
Purpose: Object storage for all media files: original photos, thumbnails, watermarked variants, and user selfies.
Used by: media-api
Keys
| Variable | Description |
|---|---|
R2_ACCOUNT_ID | Cloudflare account ID |
R2_ACCESS_KEY_ID | R2 API token access key |
R2_SECRET_ACCESS_KEY | R2 API token secret key |
R2_BUCKET_NAME | R2 bucket name |
R2_PUBLIC_URL | Bucket endpoint URL (used to construct presigned URLs) |
Key naming conventions
selfies/{userId}/{timestamp}.jpg
photos/{projectId}/{photoId}/original.jpg
photos/{projectId}/{photoId}/thumb-400.jpg
photos/{projectId}/{photoId}/thumb-800.jpg
photos/{projectId}/{photoId}/watermarked.jpgConsiderations
- Never return a public R2 URL to the client. Always issue presigned GET URLs with a 15-minute expiry.
- All uploads use presigned PUT URLs — the server never proxies file bytes.
- EXIF data (including GPS) is stripped by Sharp before writing thumbnails and watermarked variants. The original retains EXIF only on R2, never exposed to the client.
- On project deletion, R2 objects are deleted in batches by the
cleanup_project_storageworker (best-effort; orphaned files are non-harmful). - R2 is S3-compatible — use any S3 SDK. The project uses
@aws-sdk/client-s3+@aws-sdk/s3-request-presigner.
Google Cloud Run — Retina (Face Clustering)
Purpose: Stateless face embedding and clustering service. Replaces AWS Rekognition.
Used by: media-api (workers)
Keys
| Variable | Description |
|---|---|
RETINA_SERVICE_URL | Full URL to the Cloud Run service (e.g. https://retina-xxx-ew.a.run.app) |
RETINA_API_KEY | Sent as x-api-key header on every request |
How it works
process_selfieworker callsPOST /analyzewith the selfie image. Retina returns a 512-dim embedding and a face detection score (0–1). If the score is ≥ 0.9, the selfie is approved and the embedding stored; otherwise it is rejected with arejectionReason.cluster_facesworker sends the user's selfie and up to 50 project photo thumbnails per request toPOST /cluster. Retina returns per-face cluster IDs. Photos whose cluster ID matches the selfie are tagged as confirmed.
Considerations
- Workers are idempotent — running the same job twice produces the same DB state.
- Retina calls are never made synchronously inside a request handler. Always via BullMQ workers.
- The 512-dim embeddings are stored as
vector(512)using pgvector — never as JSON or text. - If
RETINA_SERVICE_URLis unset, workers skip Retina calls gracefully; selfies remain inpendingstate.
Google Maps Platform
Purpose: Location autocomplete and timezone detection in the backoffice project create/edit forms.
Used by: backoffice-web (client-side, browser only)
Keys
| Variable | Description |
|---|---|
NEXT_PUBLIC_GOOGLE_PLACES_API_KEY | Google Maps Platform API key — exposed to the browser. Must be set at build time. |
APIs enabled (all under the same key)
- Maps JavaScript API — required by
@googlemaps/js-api-loaderto bootstrap the SDK. - Places API (New) —
AutocompleteSuggestion.fetchAutocompleteSuggestions()for programmatic location search;PlacePrediction.toPlace().fetchFields(['location'])to resolve coordinates. - Time Zone API — REST call to
maps.googleapis.com/maps/api/timezone/jsonto convert coordinates to an IANA timezone string.
How it works
- As the operator types in the Location field, the component queries Places API New and shows a custom dropdown (no Google widget).
- On selection,
toPlace().fetchFields(['location'])resolves lat/lng within the same billing session. - The lat/lng is passed to the Time Zone API to auto-populate the timezone selector.
Billing session tokens The component uses AutocompleteSessionToken to group autocomplete + fetchFields calls into a single billable session. newToken() is called after fetchFields completes to start a fresh session for the next search.
Setup
- Create or select a project in Google Cloud Console.
- Enable: Maps JavaScript API, Places API (New), Time Zone API.
- Create an API key. In production, restrict it to your backoffice domain (HTTP referrer restriction) and the three APIs above.
- Set the key as
NEXT_PUBLIC_GOOGLE_PLACES_API_KEYinapps/backoffice-web/.env.local(dev) or your deployment environment (prod).
Considerations
- The key is exposed in the browser bundle — restrict it by HTTP referrer in production to prevent misuse.
- All three APIs must be enabled on the same key; enabling only Places API causes an
ApiTargetBlockedMapError. - Timezone detection is best-effort: if the Time Zone API call fails, the operator can still select a timezone manually.
- In development without a key, the location input falls back to a plain text field (autocomplete silently skipped).