← Back to Blog

Debugging Twilio Webhooks with ngrok

Twilio
Webhooks
ngrok

While migrating from a Flask-based SMS service to a new FastAPI implementation, I ran into an issue where Twilio webhooks were succeeding but not reaching my local development environment.

The Problem

The setup looked correct:

  • Local service running on port 5010
  • Webhook URL configured as https://dev-sms.buildmyagent.io/webhook/{id} which is the old Flask service
  • Twilio dashboard showing 200 OK responses
  • Frontend communicating with local service successfully

But inbound webhook messages weren't appearing in my local database, and there were no logs.

The issue was that dev-sms.buildmyagent.io pointed to our remote development server, not my local machine. Twilio was sending webhooks to the old Flask service on the remote server, which was responding successfully, while my local FastAPI service received nothing.

Solution: Using ngrok

To expose my local service to Twilio without reconfiguring DNS, which I did not have the permissions to do at the time, I used ngrok to create a public tunnel.

# Install ngrok
brew install ngrok

# Create tunnel to local service
ngrok http 5010

This provides a public URL like https://unmoody-unmashed-paola.ngrok-free.dev that forwards to localhost:5010.

Update the service configuration to use the ngrok URL:

sms-v2:
  environment:
    - BASE_PUBLIC_URL=https://unmoody-unmashed-paola.ngrok-free.dev
    - TWILIO_VALIDATE=false

Handling Signature Validation

Twilio signs webhook requests for security. When using ngrok, the signature validation fails because Twilio signs the request with the public ngrok URL, but the internal service sees a different URL.

For local development, disable validation with an environment flag:

async def validate_request_with_form(auth_token: str, request: Request, form_data: dict) -> bool:
    if not settings.TWILIO_VALIDATE:
        return True
    # Production validation logic

Make sure this flag is re-enabled in production to prevent spoofed requests.

Returning 200 for All Requests

The webhook handler should always return 200 to Twilio, even when internal processing fails. This prevents Twilio from logging errors and retrying webhooks for issues that are internal to your service.

@router.post("/webhook/{webhook_id}")
async def handle_webhook(...):
    try:
        contact = await svc.record_inbound(campaign, from_number, to_number, body, msg_sid, db)

        try:
            await svc.cancel_followups(campaign["campaignId"], str(contact["_id"]), 0, db)
        except Exception as e:
            logger.error(f"Error cancelling followups: {e}")

    except Exception as e:
        logger.error(f"Unexpected error in webhook handler: {e}")

    # Always return 200
    return _twiml_ok()

This separates external service communication from internal error handling. Twilio receives confirmation that the webhook was delivered, while errors are logged for debugging.