Skip to content
ExponaBLOG
NESTJS · DAY 091 / 100

Lessons from integrating Twilio telephony for production

Adding calling to a CRM taught me a lot about webhooks and state.

Expona Admin9 min read

Lessons from integrating Twilio telephony

Adding real-time calling to our CRM was meant to be straightforward—drop in Twilio, make calls, log them. It wasn't. I spent three weeks wrestling with webhook reliability, state management, and compliance before realizing that telephony integration is less about the API and more about building a resilient state machine. Here's what integrating Twilio telephony actually requires.

Calls are state machines driven by webhooks

The first breakthrough was understanding that a phone call isn't a simple request-response. It's a series of events firing asynchronously over minutes or hours. You initiate a call via Twilio's REST API, then Twilio fires webhooks back to your server as the call progresses: initiated, ringing, answered, in-progress, completed. Each webhook is a state transition, and your application must handle them arriving out of order, duplicated, or delayed.

In our CRM, we model a call as a state machine with explicit transitions. A call can move from pending to ringing, then to answered, then to completed. We reject invalid transitions—you can't jump from pending straight to completed without going through answered. This prevents corrupted state from bad webhook ordering.

typescript
enum CallState {
  PENDING = 'pending',
  RINGING = 'ringing',
  ANSWERED = 'answered',
  COMPLETED = 'completed',
  FAILED = 'failed',
}

interface CallEvent {
  callId: string;
  state: CallState;
  timestamp: Date;
  metadata?: Record<string, any>;
}

class CallStateManager {
  private validTransitions: Map<CallState, CallState[]> = new Map([
    [CallState.PENDING, [CallState.RINGING, CallState.FAILED]],
    [CallState.RINGING, [CallState.ANSWERED, CallState.FAILED]],
    [CallState.ANSWERED, [CallState.COMPLETED, CallState.FAILED]],
    [CallState.COMPLETED, []],
    [CallState.FAILED, []],
  ]);

  async transitionCall(
    callId: string,
    currentState: CallState,
    nextState: CallState,
  ): Promise<boolean> {
    const allowed = this.validTransitions.get(currentState) || [];
    if (!allowed.includes(nextState)) {
      console.warn(
        `Invalid transition: ${currentState} -> ${nextState}`,
      );
      return false;
    }
    // Persist transition atomically
    await this.db.callEvents.create({
      callId,
      fromState: currentState,
      toState: nextState,
      timestamp: new Date(),
    });
    return true;
  }
}

This structure saved us multiple times. When webhooks arrived out of order, the state machine rejected them safely. When a duplicate webhook for "call answered" arrived, we checked that the call was already in the answered state and idempotently updated it.

Validate Twilio signatures on every callback

Webhooks are not inherently trustworthy. Anyone who knows your webhook URL can send a fake event saying "call was answered by the CEO." Twilio signs every webhook with an HMAC-SHA1 signature included in the X-Twilio-Signature header. Validating this signature on every single callback is non-negotiable.

Twilio provides an official validation function for NestJS ecosystems, but I prefer implementing it explicitly so I understand what's happening. The process: reconstruct the exact request body and URL that Twilio signed, compute the HMAC-SHA1 using your auth token, and compare it to the header.

typescript
import * as crypto from 'crypto';

export function validateTwilioSignature(
  url: string,
  params: Record<string, any>,
  signature: string,
  authToken: string,
): boolean {
  // Reconstruct the exact string Twilio signed
  let data = url;
  const keys = Object.keys(params).sort();
  for (const key of keys) {
    data += key + params[key];
  }

  // Compute HMAC-SHA1
  const computed = crypto
    .createHmac('sha1', authToken)
    .update(data)
    .digest('base64');

  // Use constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(computed),
    Buffer.from(signature),
  );
}

@Controller('webhooks/twilio')
export class TwilioWebhookController {
  constructor(private readonly callService: CallService) {}

  @Post('call-status')
  async handleCallStatus(
    @Req() req: Request,
    @Body() body: any,
  ): Promise<string> {
    const signature = req.headers['x-twilio-signature'] as string;
    const fullUrl = `${process.env.WEBHOOK_BASE_URL}/webhooks/twilio/call-status`;

    if (!validateTwilioSignature(fullUrl, body, signature, process.env.TWILIO_AUTH_TOKEN)) {
      throw new BadRequestException('Invalid Twilio signature');
    }

    // Process the webhook
    await this.callService.updateCallState(body.CallSid, body.CallStatus);
    return '<Response></Response>'; // Return empty TwiML
  }
}

One subtle point: the URL in the signature must be the exact full URL Twilio used to call you. If your webhook runs behind a load balancer or reverse proxy, and the proxy rewrites the URL, validation fails. We learned this when deploying to AWS: ALB was stripping the query string before our handler saw it. Use environment variables for the webhook base URL and ensure it matches what you tell Twilio.

Store call events, not just final status

Early on, we only stored the final call status: "completed" or "failed." This was insufficient. Support needed to see the full timeline: when the call started ringing, how long it took to answer, when it disconnected. Compliance required an audit trail. Debugging outages meant understanding exactly which state transition broke.

The fix: treat each webhook callback as an immutable event, and store it. Your call record is now a sequence of events, not a mutable state field.

typescript
@Entity()
export class CallEvent {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  callSid: string; // Twilio's unique ID

  @Column()
  contactId: string; // CRM contact

  @Column({ type: 'enum', enum: CallState })
  state: CallState;

  @Column({ type: 'timestamp' })
  occurredAt: Date; // When Twilio says it happened

  @Column({ type: 'timestamp' })
  recordedAt: Date; // When we received the webhook

  @Column({ type: 'jsonb', nullable: true })
  metadata: {
    duration?: number;
    direction?: 'inbound' | 'outbound';
    reason?: string;
    recordingUrl?: string;
  };

  @Index()
  @Column()
  contactId: string;

  @Index()
  @Column()
  callSid: string;
}

@Injectable()
export class CallService {
  constructor(private db: DataSource) {}

  async recordCallEvent(
    callSid: string,
    contactId: string,
    state: CallState,
    metadata?: any,
  ): Promise<CallEvent> {
    const event = this.db.getRepository(CallEvent).create({
      callSid,
      contactId,
      state,
      occurredAt: new Date(), // Could come from Twilio timestamp
      recordedAt: new Date(),
      metadata,
    });
    return this.db.getRepository(CallEvent).save(event);
  }

  async getCallTimeline(callSid: string): Promise<CallEvent[]> {
    return this.db.getRepository(CallEvent).find({
      where: { callSid },
      order: { occurredAt: 'ASC' },
    });
  }
}

This event log paid for itself immediately. When a customer claimed we dropped their call, we could show the exact event sequence: call answered at 10:05:32, disconnected at 10:05:48. We discovered that webhooks sometimes arrived 30 seconds late, which we'd never have caught by only storing final state.

The moment your CRM starts recording calls, you're in compliance territory. GDPR, CCPA, and local regulations require explicit consent before recording. You must store recordings securely, delete them on schedule, and handle requests for erasure. This isn't an afterthought—design it in from the start.

Twilio can record calls automatically if you enable recording in the call initiation. But where are the recordings stored? How long do you keep them? Who can access them? Our approach:

  • Store recordings in S3 with encryption and versioning disabled (immutable).
  • Tag each recording with the consent status and the date it must be deleted.
  • Run a nightly Lambda that deletes expired recordings and updates call records.
  • Never expose recording URLs directly; fetch through an authenticated endpoint that logs access.
typescript
@Injectable()
export class RecordingService {
  constructor(
    private s3: S3Client,
    private db: DataSource,
  ) {}

  async initiateRecordedCall(
    contactId: string,
    phoneNumber: string,
    consentGiven: boolean,
  ): Promise<string> {
    if (!consentGiven) {
      throw new BadRequestException('Recording requires explicit consent');
    }

    const retentionDays = 30; // Adjust per your policy
    const deleteDate = new Date();
    deleteDate.setDate(deleteDate.getDate() + retentionDays);

    // Call Twilio API to initiate call with recording
    const call = await twilio.calls.create({
      to: phoneNumber,
      from: process.env.TWILIO_FROM_NUMBER,
      record: true,
      recordingStatusCallback: `${process.env.WEBHOOK_BASE_URL}/webhooks/twilio/recording-complete`,
      recordingStatusCallbackEvent: ['completed'],
    });

    // Store metadata
    await this.db.getRepository(CallRecording).save({
      callSid: call.sid,
      contactId,
      consentGiven: true,
      consentRecordedAt: new Date(),
      recordingDeleteDate: deleteDate,
      recordingUrl: null, // Will be filled when recording completes
    });

    return call.sid;
  }

  async handleRecordingComplete(callSid: string, recordingUrl: string): Promise<void> {
    // Download recording from Twilio and store in S3
    const response = await fetch(recordingUrl);
    const buffer = await response.arrayBuffer();

    const s3Key = `recordings/${callSid}.wav`;
    await this.s3.send(
      new PutObjectCommand({
        Bucket: process.env.S3_BUCKET,
        Key: s3Key,
        Body: new Uint8Array(buffer),
        ServerSideEncryption: 'AES256',
        Metadata: {
          callSid,
          uploadedAt: new Date().toISOString(),
        },
      }),
    );

    // Update database
    await this.db.getRepository(CallRecording).update(
      { callSid },
      { recordingUrl: s3Key, recordingStoredAt: new Date() },
    );
  }
}

Build an audit log for recording access. Log who requested the recording, when, and why. This helps you respond to data access requests and detect misuse.

Common mistakes

  • Assuming webhooks arrive in order: They don't. Always check current state before transitioning. A webhook for "call answered" arriving after "call completed" should be ignored, not crash your system.
  • Skipping signature validation: I've seen teams disable this in "development" and forget to re-enable it. Make it a compile-time requirement via middleware.
  • Storing only the latest state: The call record becomes a black box. Future you, your ops team, and regulators will thank you for storing events.
  • Ignoring recording compliance: Add it as a feature flag or explicit parameter from day one. Retrofitting consent logic after recording thousands of calls is a nightmare.
  • Not handling webhook retries: Twilio retries failed webhooks multiple times. Ensure your handlers are idempotent so receiving the same event twice doesn't create duplicate records.

Key takeaways

  • Model calls as explicit state machines with validated transitions. Webhooks arriving out of order won't corrupt your data.
  • Validate Twilio signatures on every callback using constant-time comparison. This is not optional.
  • Store events, not just final status. You'll need the timeline for debugging, support, and compliance.
  • Plan recording retention, consent, and access logging from the start. Compliance isn't a feature you bolt on later.
  • Make webhooks idempotent. Duplicate events should be safely absorbed, not cause duplicate records.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles