Skip to Content
Internal docs are powered by Nextra Docs Theme.
SystemsPlatformAPI2Rails to API2 migrationPackagesNative Unit21 webhook ingress

Native Unit21 webhook ingress

API2 natively serves POST /webhooks/unit21/:organization_data_source_id (Linear ENG-3224 ). The route is registered method-specifically on the composed app, so the canonical gateway no longer forwards it to Rails while LEGACY_RAILS_FALLBACK_ENABLED stays true. The Rails route and controller remain deployed for rollback.

Code: rulebase-web/rulebase-api2/src/routes/unit21-webhooks.ts and src/lib/unit21/webhook-ingress.ts. Rails reference: rulebase-api/app/controllers/webhooks_controller.rb and OrganizationDataSource#log_event.

Contract

Order of checks matches WebhooksController so the failure distribution is unchanged:

  1. Unit21-Signature is parsed as t=<ts>,s0=<hex> (extract_unit21_signature_parts semantics: blank parts dropped, later duplicates win). Missing or unusable header returns Rails’ Pundit 403 envelope.
  2. With Content-Type: application/json, an unparseable body returns Rails’ 400 (ActionDispatch params parse error).
  3. The source is resolved without tenant context: connection_ prefix ID, discarded_at IS NULL, data_sources.key = 'unit21'. Any failing fence returns {"error":"Not Found"} 404; the response does not reveal which fence failed.
  4. webhook_secret is read from the Active Record–encrypted data jsonb; a blank secret returns 403.
  5. HMAC-SHA256 over "<t>." + raw body bytes is compared in constant time only when lengths match; mismatch returns 403.
  6. One connection_logs row is inserted for the resolved source’s organization_id with {event: "webhook.unit21.received", headers, body}. Headers are the compacted content-length, content-type, unit21-signature, user-agent. The body is stored the way Rails stores a binary raw_post re-encoded to UTF-8: every non-ASCII byte becomes U+FFFD. Signature verification always uses the untouched raw bytes.
  7. SET organization_data_source/<id>/connection_log_last_sync_touch api2 NX PX 60000 on the shared regional Redis (Rails’ redis_cache_store uses the same key with no namespace). Only the winner updates last_synced_at/updated_at and records provider_sync / checkpoint_advanced {provider: "unit21", trigger: "webhook"}. Rails and API2 therefore share one one-minute window per source during mixed ownership.
  8. Response is exactly 200 {"message":"OK"}.
  9. After recording an accepted event, API2 enqueues the source’s Unit21 sync with a ten-second debounce. A burst of deliveries coalesces onto the same deterministic source job; syncEnqueue is enqueued, coalesced, paused, or failed. If a delivery coalesces into an active job after that job has fetched its provider data, API2 records a short-lived successor marker and queues a follow-up source job after the active job completes. There is one residual millisecond-scale window where a webhook reads active just as the job completes and sets its marker after the completion handler ran; that marker is consumed by the next job’s completion, causing at most one extra sync within the 30-minute TTL, while the event remains covered by the ten-minute scheduled tick. The existing pause lever prevents enqueueing without changing the webhook response. A failed enqueue still returns 200, and the ten-minute scheduled tick remains the recovery backstop; alert on the rate of syncEnqueue=failed. See ENG-3294 .

No transaction or row lock is held across the Redis claim; the insert and the touch are independent auto-committed statements, the same shape as Rails’ create! then touch. A connection_logs insert failure propagates as 500 so Unit21 retries, as with Rails.

GET/HEAD on the path are not registered and still reach the gateway (Rails returns 404 for them). OPTIONS preflight with Access-Control-Request-Method: POST is answered natively by the composed app’s CORS layer.

Deviations

  • Bodies larger than 1 MiB return 413 before hashing. Rails accepts anything the edge passes; Unit21 payloads are a few KiB.
  • The Redis claim is skipped (treated as “not claimed”) when Redis is unavailable; Rails’ cache store also swallows Redis errors and returns a falsy write result. Neither implementation fails the webhook on a cache outage.
  • No replay deduplication is added; Rails has none. Identical deliveries each write a connection_logs row.
  • Structured log event native_unit21_webhook carries outcome, status, sourceId, connectionLogId, and touched; nothing from the payload, signature, or secret.

Rollback

There is no flag. Rails still routes and serves the endpoint, so rollback is a code revert of the API2 PR: once the route registration disappears, the gateway forwards POST /webhooks/unit21/* to Rails again while LEGACY_RAILS_FALLBACK_ENABLED is true. Do not disable the fallback or delete the Rails route until the zero-Rails window below has been observed.

Production rollout plan

Baseline (rolling 24 hours ending about 2026-09-17T20:10Z): US 473 POST /webhooks/unit21/:connection, all 200; EU zero. Seven-day structured gateway telemetry does not yet exist; do not extrapolate.

  1. Deploy. Confirm in each region that legacy_rails_gateway events for /webhooks/unit21/* drop to zero and native_unit21_webhook events appear.
  2. Canary against the real public hostname in both US and EU with a non-production Unit21 source: a signed request (200, one new connection_logs row, last_synced_at advanced once per minute), a wrong signature (403), a tampered prefix ID (404), a discarded source (404).
  3. Reconcile for the first complete 24 hours per region: native_unit21_webhook accepted count = new connection_logs rows with data->>'event' = 'webhook.unit21.received' = Unit21’s delivery count for the tenant; touched: true count ≈ provider_sync/checkpoint_advanced trigger=webhook events; status distribution comparable to the Rails baseline (US: all 200).
  4. Hold a complete rolling 24-hour and seven-day window with zero forwarded /webhooks/unit21/* requests in both regions before proposing removal of the Rails route in a separate package.

Local checks

cd rulebase-web/rulebase-api2 pnpm vitest run src/routes/unit21-webhooks src/lib/unit21/webhook src/app.route-ownership.test.ts src/lib/legacy-rails-gateway pnpm typecheck
Last updated on