Skip to content

Commit e52ddb5

Browse files
committed
fix(triggers): key Twilio status callbacks by SID + status for idempotency
Twilio sends multiple delivery callbacks per message (sent -> delivered -> ...) sharing one MessageSid; keying idempotency on the SID alone dropped every status after the first. Status callbacks now key on SID + delivery status so each state is distinct (while still deduping Twilio's retries of the same status); inbound messages still key by SID since they fire once.
1 parent 1c6ca3e commit e52ddb5

2 files changed

Lines changed: 25 additions & 2 deletions

File tree

apps/sim/lib/webhooks/providers/twilio.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,23 @@ describe('twilioHandler', () => {
128128

129129
describe('extractIdempotencyId', () => {
130130
it('prefers MessageSid, falls back to CallSid', () => {
131-
expect(twilioHandler.extractIdempotencyId!({ MessageSid: 'SM1' })).toBe('SM1')
131+
expect(
132+
twilioHandler.extractIdempotencyId!({ MessageSid: 'SM1', SmsStatus: 'received' })
133+
).toBe('SM1')
132134
expect(twilioHandler.extractIdempotencyId!({ CallSid: 'CA1' })).toBe('CA1')
133135
expect(twilioHandler.extractIdempotencyId!({})).toBeNull()
134136
})
137+
138+
it('keys status callbacks by SID + status so each delivery state is distinct', () => {
139+
const sent = twilioHandler.extractIdempotencyId!({ MessageSid: 'SM1', MessageStatus: 'sent' })
140+
const delivered = twilioHandler.extractIdempotencyId!({
141+
MessageSid: 'SM1',
142+
MessageStatus: 'delivered',
143+
})
144+
expect(sent).toBe('SM1:sent')
145+
expect(delivered).toBe('SM1:delivered')
146+
expect(sent).not.toBe(delivered)
147+
})
135148
})
136149

137150
describe('matchEvent', () => {

apps/sim/lib/webhooks/providers/twilio.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,17 @@ export const twilioHandler: WebhookProviderHandler = {
4444

4545
extractIdempotencyId(body: unknown) {
4646
const obj = body as Record<string, unknown>
47-
return (obj.MessageSid as string) || (obj.CallSid as string) || null
47+
const sid = (obj.MessageSid as string) || (obj.CallSid as string)
48+
if (!sid) return null
49+
// Status callbacks repeat for the same SID as the message progresses
50+
// (sent -> delivered -> ...), so the delivery status is part of the key to
51+
// keep each distinct callback (while still deduping Twilio's retries of the
52+
// same status). Inbound messages fire once (SmsStatus 'received'), keyed by SID.
53+
const status = (
54+
((obj.MessageStatus as string) || (obj.SmsStatus as string)) ??
55+
''
56+
).toLowerCase()
57+
return status && status !== 'received' ? `${sid}:${status}` : sid
4858
},
4959

5060
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {

0 commit comments

Comments
 (0)