Skip to content

fix(triggers): repair broken idempotency dedup across 4 webhook/polling triggers#5524

Merged
waleedlatif1 merged 13 commits into
stagingfrom
validate-triggers-batch
Jul 9, 2026
Merged

fix(triggers): repair broken idempotency dedup across 4 webhook/polling triggers#5524
waleedlatif1 merged 13 commits into
stagingfrom
validate-triggers-batch

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • Ran /validate-trigger audits on 6 triggers (incidentio, notion, instantly, gitlab, gmail, linear) against each service's live API docs, then a second independent adversarial re-audit on every trigger that needed a fix
  • incidentio, notion: fully clean on the first pass, nothing to fix
  • instantly: extractIdempotencyId was entirely missing — every delivery, including retries, got a random dedup key. Fixed, then the re-audit caught that email_id alone isn't unique per-occurrence (Instantly can fire the same event type repeatedly against one email — repeat opens/clicks, thread replies), so a second legitimate event would've been dropped for 7 days. Fixed by appending the fixed-per-occurrence timestamp to the key
  • gitlab: same missing-idempotency bug, plus a reserved TriggerOutput.type meta-key collision on Issue Hook payloads (GitLab 17.2+ adds object_attributes.type). Fixed, then the re-audit found GitLab sets checkout_sha to null/all-zeros on branch deletion, so the fallback key collapsed every branch deletion in a project onto one identical key — fixed by including ref in the key. Also kept the raw type field alongside the new work_item_type in the delivered data, since the old code did a full passthrough and any workflow already referencing the undocumented raw path shouldn't silently break
  • gmail: labels output was mistyped as string when the real delivered value is always an array. Fixed to 'json', then the re-audit refined it to 'array' to match both the runtime shape and the codebase's own convention (github/jira triggers use 'array' for the identical field)
  • linear: added a missing idempotency fallback and deduped HMAC verification against the shared createHmacVerifier helper. Also tightened the replay-window from 5min to 60s citing a claim about Linear's webhookTimestamp semantics — the re-audit found that claim isn't actually verifiable from Linear's docs, and reverted it to the conservative 5-minute window given the asymmetric risk (a wrong guess could silently and permanently drop legitimate 1hr/6hr retries)
  • Extracted a shared buildFallbackDeliveryFingerprint helper (used by the Ashby fix in a companion PR) — not duplicated here, already shared via providers/utils.ts

Type of Change

  • Bug fix

Testing

  • 219/219 tests passing across the full lib/webhooks/providers/ suite (24 files)
  • bunx turbo run type-check --force clean (forced, non-cached — a cached run gave a false pass earlier in this workstream)
  • bunx biome check clean on all touched files
  • bun run check:api-validation passed
  • Every fix independently re-verified in a second, separate adversarial audit pass with live docs re-fetched from scratch — 3 of 4 fixed triggers had real bugs caught only by that second pass, all now fixed and covered by regression tests

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Instantly webhook deliveries had no extractIdempotencyId, so retried
deliveries were never deduped. Instantly doesn't send a delivery-id
header, so key on email_id when present (unique per email/reply) and
fall back to a content-based key (event_type + campaign_id + lead_email
+ event timestamp) otherwise. Added instantly.test.ts covering auth,
event matching, formatInput/output alignment, idempotency, and the
subscription lifecycle.
…lision risk

extractIdempotencyId was missing entirely, so GitLab's automatic webhook
retries (triggered after timeouts/5xx, up to 4 consecutive failures before
temporary disable) would re-execute the workflow as a brand-new event.
Added a content-derived fallback key (checkout_sha for push, object
id + updated_at otherwise) since GitLab's X-Gitlab-Event-UUID delivery
header isn't available to extractIdempotencyId (body-only).

Also renamed the Issue Hook payload's object_attributes.type (GitLab
17.2+ work item type) to work_item_type in formatInput and exposed it
in buildGitLabIssueOutputs, since 'type' collides with the TriggerOutput
meta-key. Added detailed_merge_status to the merge request outputs to
match the deprecation of merge_status.

Added apps/sim/lib/webhooks/providers/gitlab.test.ts covering verifyAuth,
matchEvent, formatInput, and extractIdempotencyId.
…gnment test

The gmail_poller trigger declared its `labels` output as type: 'string'
while the description and actual runtime value (email.labelIds) are a
string array. Changed to type: 'json' to match the shape actually
delivered, consistent with how other triggers type array-of-string
fields.

Added apps/sim/lib/webhooks/providers/gmail.test.ts covering
gmailHandler.formatInput passthrough behavior and a regression check
that every key formatInput can deliver on `email` matches a key
declared in the trigger's output schema.
…cy fallback

- Reuse the shared createHmacVerifier helper for Linear's signature check
  instead of hand-rolling header/secret validation.
- Tighten the webhookTimestamp replay-protection skew from 5 minutes to 60
  seconds, matching Linear's documented recommendation.
- Add extractIdempotencyId as a content-based fallback (type:action:id:
  updatedAt) for when the Linear-Delivery header is unavailable.
- Remove unused teamOutputs/stateOutputs dead code from triggers/linear/utils.ts.
…type

formatInput previously spread the entire raw body, so object_attributes.type
(GitLab 17.2+ work item type) was already reachable via a manual expression
even though undocumented. The earlier fix renamed it to work_item_type in
the delivered data too, which would silently break any pre-existing
workflow referencing the raw path. Keep both keys in the delivered data —
this is plain passthrough data, not schema-constrained, so there's no
reserved-key collision risk in doing so; only the declared *schema* needs
the work_item_type name.
…_id repeats

Instantly's is_first field ("whether this is the first event of this
type for the lead") only makes sense if the same event_type can fire
more than once for the same email_id: every open of the same email,
every click, and every reply in a thread all share one email_id
(Instantly's reply_to_uuid). Keying idempotency on email_id alone
collapsed all of those distinct, legitimate deliveries into a single
dedup slot, so every open/click/reply after the first was silently
dropped for the 7-day idempotency TTL. Append timestamp (fixed per
event occurrence, confirmed not to regenerate on retry) to keep the
key both retry-stable and unique per occurrence.
…ics unverified

The prior pass tightened LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS from 5 minutes to
Linear's suggested 60 seconds, justified by the claim that webhookTimestamp
is re-stamped fresh on every delivery/retry attempt. That claim is not
stated anywhere in Linear's webhook docs (verified against the raw docs
page directly, including the exact wording around retries and the
webhookTimestamp field) — the docs only say it is "the time when the
webhook was sent" with no mention of retry behavior. A third-party
implementation guide explicitly recommends a 5-minute window instead of
Linear's literal 60s suggestion, citing this same ambiguity.

If webhookTimestamp is actually fixed to the original event time (not
refreshed per attempt), a strict 60s window would silently and permanently
reject every one of Linear's own 1hr/6hr retry deliveries following any
transient outage on our side, since Linear gives up after 3 failed
attempts. Idempotency dedup (Linear-Delivery header / extractIdempotencyId
fallback) already prevents double-processing of replayed/retried
deliveries within a wider window, so the extra replay-protection from
matching the literal 60s suggestion is marginal next to the risk of
dropping real business events. Reverting to the 5-minute window pending
explicit confirmation from Linear on retry timestamp semantics.
…tion collisions

GitLab sets checkout_sha to null on branch/tag deletion, so the
extractIdempotencyId fallback (checkout_sha || after) degenerated to the
all-zeros SHA for every deletion. Without ref in the key, two unrelated
branch deletions in the same project produced the same idempotency key,
silently dropping the second (and any subsequent) legitimate trigger
execution as a false duplicate.
The prior pass changed the gmail_poller trigger's `labels` output from
'string' to 'json', which is directionally correct (labelIds is a
string array, not a string) but not the precise type. The codebase's
own PrimitiveValueType union has a dedicated 'array' variant, used by
sibling triggers with identical semantics (github issue/PR triggers,
Jira triggers all declare their `labels` field as type: 'array').

Concretely, 'json' vs 'array' diverges in generateMockValue
(apps/sim/lib/workflows/triggers/trigger-utils.ts), which backs the
auto-generated "Event Payload Example" sample payload shown in the
trigger config UI (apps/sim/triggers/index.ts, gated on
trigger.id.includes('poller') — gmail_poller qualifies). 'json' mocks
as a single generic object ({id, name, status}), actively misleading
for a field that is always an array. 'array' mocks as a list, matching
the true shape (Gmail API docs confirm labelIds is a string array on
the Message resource).

No behavioral difference in output-path validation/resolution
(isPathInSchema, generateOutputPaths) since the field declares no
items/properties either way — this is purely a type-declaration
correctness fix, verified against Gmail API docs and cross-checked
against every other output field in poller.ts (all match runtime
shape).
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jul 9, 2026 12:30am

Request Review

@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes affect which webhook deliveries run workflows (dedup keys and Linear timestamp skew); incorrect keys could drop legitimate events or allow duplicates, though behavior is heavily tested and often skips dedup when data is insufficient.

Overview
Fixes broken or missing webhook deduplication and a few trigger payload/schema mismatches for GitLab, Instantly, Linear, and Gmail polling.

GitLab adds extractIdempotencyId (push/tag_push with ref + SHA, other events via object_attributes, pipeline status lifecycle) and extends formatInput to mirror GitLab 17.2+ object_attributes.type as work_item_type while keeping the raw type field. Issue trigger outputs gain work_item_type; MR outputs note deprecated merge_status and add detailed_merge_status.

Instantly adds extractIdempotencyId keyed by event_type + timestamp, using email_id or campaign/lead fallbacks so retries dedupe without collapsing repeat opens/clicks on the same email.

Linear adds extractIdempotencyId, routes signature checks through createHmacVerifier, hardens matchEvent/formatInput against null bodies, and documents keeping a 5-minute webhookTimestamp skew (not a strict 60s window) so delayed retries are less likely to be rejected.

Gmail corrects the polled email.labels output type from string to array.

Large Vitest suites cover auth, matching, formatting, idempotency edge cases, and Instantly subscription lifecycle.

Reviewed by Cursor Bugbot for commit 96acfc4. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes idempotency and schema issues across several webhook and polling triggers. The main changes are:

  • Adds fallback idempotency keys for Instantly, GitLab, and Linear events.
  • Avoids Instantly false deduplication when no per-event timestamp is available.
  • Makes Linear null-body handling safer and reuses the shared HMAC verifier.
  • Adds GitLab payload formatting for work_item_type while keeping the raw field.
  • Corrects the Gmail labels output type to an array.
  • Adds provider regression tests for the updated trigger behavior.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Important Files Changed

Filename Overview
apps/sim/lib/webhooks/providers/instantly.ts Adds Instantly body-derived idempotency keys and skips unsafe keys when no timestamp is present.
apps/sim/lib/webhooks/providers/linear.ts Adds Linear idempotency fallback logic, safer body guards, and shared signature verification.
apps/sim/lib/webhooks/providers/gitlab.ts Adds GitLab fallback idempotency keys and formats issue work item type data.
apps/sim/triggers/gmail/poller.ts Updates the Gmail labels output schema to match the delivered array value.

Reviews (3): Last reviewed commit: "fix(gitlab): distinguish pipeline lifecy..." | Re-trigger Greptile

Comment thread apps/sim/lib/webhooks/providers/instantly.ts
Comment thread apps/sim/lib/webhooks/providers/linear.ts
…o TSDoc

GitLab does not automatically retry failed webhook deliveries — a failed
request only counts toward auto-disabling the webhook (4 consecutive
failures = temporary disable, 40 = permanent), and re-delivery only
happens via a manual "Resend Request" (UI or API), which carries the
same webhook-id/Idempotency-Key/X-Gitlab-Event-UUID headers as the
original delivery. Those headers are already in the shared idempotency
service's allowlist and checked ahead of extractIdempotencyId, so the
body-based fallback matters for the rare case those headers are
stripped in transit — not for "automatic retries" as previously stated.
Verified directly against docs.gitlab.com.

Also replaced inline `//` comments across the gitlab/gmail/linear/
instantly provider and trigger files with TSDoc blocks on the relevant
declaration where the reasoning was non-obvious, or removed them
outright where they only restated adjacent code.
instantly: the timestamp-present branch was fixed for the email_id
collision risk, but the no-timestamp fallback still keyed on bare
email_id — same collision risk, unfixed. Return null instead so dedup
is skipped rather than risking a false collision between repeat opens/
clicks/replies on the same email.

linear: extractIdempotencyId cast `body` straight to a Record without
checking it was actually an object first, so a JSON `null` body (no
Linear-Delivery header available) would throw on `b.type` and turn a
malformed payload into a webhook 500 instead of a clean skip.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/webhooks/providers/gitlab.ts Outdated
Found during a final backwards-compat pass, same class of bug Greptile
just caught in extractIdempotencyId: both cast body straight to a
Record without checking it was actually an object, so a genuinely
null/malformed body would throw instead of degrading gracefully.
gitlab.ts (asRecord's `|| {}`) and instantly.ts (isRecordLike checks)
already guard this everywhere; linear.ts was the one inconsistent
provider. Uses the same isRecordLike helper as instantly.ts.
…y key

Pipeline Hook payloads have no updated_at field (confirmed against
docs.gitlab.com — only Issue/Merge Request/Note hooks reliably include
it), so every lifecycle transition of the same pipeline (pending ->
running -> success/failed) collapsed onto the identical fallback key
and later real transitions were skipped as duplicates. Falls back to
status + finished_at/created_at when updated_at is absent.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 96acfc4. Configure here.

@waleedlatif1 waleedlatif1 merged commit 1ee98a6 into staging Jul 9, 2026
13 checks passed
@waleedlatif1 waleedlatif1 deleted the validate-triggers-batch branch July 9, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant