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:
Unit21-Signatureis parsed ast=<ts>,s0=<hex>(extract_unit21_signature_partssemantics: blank parts dropped, later duplicates win). Missing or unusable header returns Rails’ Pundit403envelope.- With
Content-Type: application/json, an unparseable body returns Rails’400(ActionDispatchparams parse error). - 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. webhook_secretis read from the Active Record–encrypteddatajsonb; a blank secret returns403.- HMAC-SHA256 over
"<t>." + raw body bytesis compared in constant time only when lengths match; mismatch returns403. - One
connection_logsrow is inserted for the resolved source’sorganization_idwith{event: "webhook.unit21.received", headers, body}. Headers are the compactedcontent-length,content-type,unit21-signature,user-agent. The body is stored the way Rails stores a binaryraw_postre-encoded to UTF-8: every non-ASCII byte becomes U+FFFD. Signature verification always uses the untouched raw bytes. SET organization_data_source/<id>/connection_log_last_sync_touch api2 NX PX 60000on the shared regional Redis (Rails’redis_cache_storeuses the same key with no namespace). Only the winner updateslast_synced_at/updated_atand recordsprovider_sync/checkpoint_advanced{provider: "unit21", trigger: "webhook"}. Rails and API2 therefore share one one-minute window per source during mixed ownership.- Response is exactly
200 {"message":"OK"}. - 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;
syncEnqueueisenqueued,coalesced,paused, orfailed. 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 readsactivejust 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. Afailedenqueue still returns200, and the ten-minute scheduled tick remains the recovery backstop; alert on the rate ofsyncEnqueue=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
413before 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_logsrow. - Structured log event
native_unit21_webhookcarriesoutcome,status,sourceId,connectionLogId, andtouched; 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.
- Deploy. Confirm in each region that
legacy_rails_gatewayevents for/webhooks/unit21/*drop to zero andnative_unit21_webhookevents appear. - Canary against the real public hostname in both US and EU with a
non-production Unit21 source: a signed request (
200, one newconnection_logsrow,last_synced_atadvanced once per minute), a wrong signature (403), a tampered prefix ID (404), a discarded source (404). - Reconcile for the first complete 24 hours per region:
native_unit21_webhookacceptedcount = newconnection_logsrows withdata->>'event' = 'webhook.unit21.received'= Unit21’s delivery count for the tenant;touched: truecount ≈provider_sync/checkpoint_advancedtrigger=webhookevents; status distribution comparable to the Rails baseline (US: all200). - 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