code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Handling Sinch Conversation API Callbacks with NestJS

A guide on building a production-ready webhook handler using Node.js and NestJS to securely receive, validate, and process callbacks from the Sinch Conversation API.

Handling Sinch Conversation API callbacks with NestJS

Real-time updates are crucial for modern messaging applications. Knowing when a message is delivered, read, or when a user replies is essential for building responsive and reliable communication flows. Polling APIs for status updates is inefficient and slow. Sinch's Conversation API solves this by using webhooks to push near real-time notifications (callbacks) directly to your application.

This guide provides a step-by-step walkthrough for building a production-ready webhook handler using Node.js and the NestJS framework to securely receive, validate, and process callbacks from the Sinch Conversation API. We will focus on handling message delivery status updates (MESSAGE_DELIVERY) and inbound messages (MESSAGE_INBOUND).

By the end of this guide, you will have a robust NestJS application capable of:

  • Receiving HTTP POST requests from Sinch webhooks.
  • Securely validating incoming requests using HMAC signatures.
  • Parsing different Sinch callback event types.
  • Logging callback details for monitoring and debugging.
  • Responding correctly to Sinch to acknowledge receipt and prevent unnecessary retries.

Technologies Used:

  • Node.js: The JavaScript runtime environment.
  • NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications using TypeScript. Its modular architecture and built-in features make it ideal for creating robust API endpoints.
  • Sinch Conversation API: The Sinch service providing messaging capabilities and webhook notifications.
  • ngrok (or alternative): A tool to expose your local development server to the internet, allowing Sinch to send callbacks to it during testing. Essential for the local testing workflow described here, but optional if you already have a publicly accessible development server.

System Architecture:

The basic flow is straightforward:

mermaid
graph LR
    A[Sinch Platform] -- 1. Sends Message --> B(End User Channel e.g., SMS, WhatsApp);
    B -- 2. Sends Reply / Channel Confirms Delivery --> A;
    A -- 3. Triggers Webhook (HTTP POST) --> C{Your NestJS Application Endpoint};
    C -- 4. Validates HMAC Signature --> D[Process Callback Logic];
    D -- 5. Logs Event / Updates Database / etc. --> E(Monitoring/Datastore);
    C -- 6. Sends 200 OK Response --> A;

Prerequisites:

  • Node.js (LTS version recommended) and npm (or yarn) installed.
  • NestJS CLI installed (npm install -g @nestjs/cli).
  • A Sinch account with access to the Conversation API. You'll need your Project ID and an App ID.
  • Sinch API Credentials: To trigger test messages or configure webhooks via the API (shown later), you will need API credentials, typically a Service Plan ID and API Token, or an API Key and Secret, depending on your authentication method. These are used to obtain access tokens.
  • A publicly accessible HTTPS URL for your webhook endpoint. During local development, ngrok is highly recommended. If using the .env configuration below (PORT=3000), you would run: ngrok http 3000.
  • Basic understanding of TypeScript and REST APIs.

1. Setting up the NestJS project

Let's create a new NestJS project and configure it to handle Sinch callbacks.

1. Create a new NestJS project:

Open your terminal and run the NestJS CLI command:

bash
# Using npm
nest new sinch-callback-handler

# Or using yarn
# nest new sinch-callback-handler --package-manager yarn

cd sinch-callback-handler

This command scaffolds a new project with the necessary base structure.

2. Install Dependencies:

We need the @nestjs/config module to manage environment variables securely.

bash
# Using npm
npm install @nestjs/config

# Or using yarn
# yarn add @nestjs/config

3. Configure Environment Variables:

Sensitive information like API keys or webhook secrets should never be hardcoded. We'll use environment variables.

Create a .env file in the project root:

dotenv
# .env

# Port the NestJS application will run on
PORT=3000

# The secret key configured for your Sinch webhook (generate a strong one)
SINCH_WEBHOOK_SECRET=your_strong_sinch_webhook_secret_here
  • PORT: Specifies the port your local server will listen on (used for ngrok http 3000).
  • SINCH_WEBHOOK_SECRET: This must match the secret you configure in the Sinch portal for your webhook. It's used for HMAC signature validation.

Create a .env.example file to document required variables (useful for collaborators):

dotenv
# .env.example

PORT=
SINCH_WEBHOOK_SECRET=

Ensure .env is added to your .gitignore file to prevent accidentally committing secrets:

text
# .gitignore (ensure this line exists or add it)
.env

4. Enable Raw Body Parsing:

Sinch's HMAC signature validation requires access to the raw, unparsed request body. NestJS needs to be configured to make this raw body available.

Modify src/main.ts to enable the rawBody option in the NestFactory.create call.

typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
// import * as express from 'express'; // Not needed for rawBody: true
import { ConfigService } from '@nestjs/config';
import { Logger, ValidationPipe } from '@nestjs/common'; // Import Logger

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    // Enable rawBody property on request object
    // This makes the raw request buffer available at req.rawBody
    rawBody: true,
  });

  const configService = app.get(ConfigService);
  const port = configService.get<number>('PORT', 3000); // Use ConfigService

  // Apply global pipes for validation if needed elsewhere
  app.useGlobalPipes(new ValidationPipe());

  // IMPORTANT: NestJS's standard JSON body parsing still works even with
  // rawBody: true. The raw buffer is simply made available *in addition*
  // to the parsed body (if applicable). No need to add express.raw() here.

  await app.listen(port);
  Logger.log(`Application listening on port ${port}`, 'Bootstrap'); // Use NestJS Logger
}
bootstrap();

Explanation:

  • rawBody: true in NestFactory.create tells NestJS's underlying adapter (usually Express) to keep the original raw buffer accessible via req.rawBody on the Request object, which is essential for HMAC validation.
  • We fetch the PORT using the ConfigService.
  • We use the NestJS Logger for better-structured logging.

5. Load Configuration Module:

Import and register the ConfigModule in your main application module (src/app.module.ts) to make environment variables accessible throughout the application via the ConfigService.

typescript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config'; // Import ConfigModule
import { WebhookModule } from './webhook/webhook.module'; // Import WebhookModule (we'll create this next)

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true, // Makes ConfigService available globally
      envFilePath: '.env', // Specify the env file path
    }),
    WebhookModule, // Add WebhookModule here
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
  • ConfigModule.forRoot({...}): Initializes the configuration module.
  • isGlobal: true: Allows injecting ConfigService anywhere without importing ConfigModule in feature modules.
  • envFilePath: '.env': Tells the module where to find the environment variables file.

Our basic project structure and configuration are now ready.

2. Implementing the webhook handler

Now, let's create the module, controller, and service responsible for handling incoming Sinch callbacks.

1. Generate the Webhook Module, Controller, and Service:

Use the NestJS CLI to generate these components:

bash
nest g module webhook
nest g controller webhook --flat --no-spec # --flat puts controller in webhook/, --no-spec skips test file
nest g service webhook --flat --no-spec   # --flat puts service in webhook/, --no-spec skips test file

This creates:

  • src/webhook/webhook.module.ts
  • src/webhook/webhook.controller.ts
  • src/webhook/webhook.service.ts

Remember to import WebhookModule into AppModule as shown in the previous step.

2. Implement the Webhook Controller:

The controller defines the route (/webhook in this case) and handles the incoming HTTP request. It extracts necessary information (headers, raw body) and passes it to the service for validation and processing.

typescript
// src/webhook/webhook.controller.ts
import {
  Controller,
  Post,
  Req,
  Res,
  Headers,
  Logger,
  HttpCode,
  HttpStatus,
  RawBodyRequest, // Import RawBodyRequest
} from '@nestjs/common';
import { Request, Response } from 'express'; // Import Express types
import { WebhookService } from './webhook.service';

@Controller('webhook') // Route prefix for all methods in this controller
export class WebhookController {
  private readonly logger = new Logger(WebhookController.name);

  constructor(private readonly webhookService: WebhookService) {}

  @Post() // Handles POST requests to /webhook
  @HttpCode(HttpStatus.OK) // Set default success code to 200
  async handleWebhook(
    @Headers() headers: Record<string, string>,
    @Req() req: RawBodyRequest<Request>, // Use RawBodyRequest type
    @Res() res: Response, // Inject Response object to send custom response
  ): Promise<void> {
    this.logger.log('Received webhook request');

    // Access raw body from the request object (enabled in main.ts via rawBody: true)
    const rawBody = req.rawBody;
    if (!rawBody) {
      this.logger.error('Raw body missing from request.');
      // Send a Bad Request response if raw body isn't available
      res.status(HttpStatus.BAD_REQUEST).send('Raw body is required.');
      return;
    }

    // Perform HMAC validation
    const isValid = this.webhookService.validateSignature(headers, rawBody);

    if (!isValid) {
      this.logger.warn('Invalid webhook signature received.');
      // Send a Forbidden response if signature is invalid
      res.status(HttpStatus.FORBIDDEN).send('Invalid signature.');
      return;
    }

    this.logger.log('Webhook signature validated successfully.');

    try {
      // Parse the raw body as JSON *after* validation
      const payload = JSON.parse(rawBody.toString('utf-8'));

      // Process the validated payload asynchronously
      // We don't await this to respond quickly to Sinch
      this.webhookService.processCallback(payload).catch((error) => {
        // Log processing errors but don't let them block the 200 OK response
        this.logger.error(
          `Error processing webhook payload: ${error.message}`,
          error.stack,
        );
      });

      // IMPORTANT: Send a 200 OK response *immediately* after validation
      // This acknowledges receipt to Sinch and prevents retries.
      res.status(HttpStatus.OK).send('Webhook received successfully.');
    } catch (error) {
      this.logger.error(
        `Error parsing webhook JSON payload: ${error.message}`,
        rawBody.toString('utf-8'), // Log the raw body on parse error
      );
      // Send a Bad Request if JSON parsing fails
      res
        .status(HttpStatus.BAD_REQUEST)
        .send('Invalid JSON payload received.');
    }
  }
}

Explanation:

  • @Controller('webhook'): Defines the base route /webhook.
  • @Post(): Decorator for the HTTP POST method handler.
  • @HttpCode(HttpStatus.OK): Sets the default success status code to 200 OK.
  • @Headers() headers: Injects all request headers.
  • @Req() req: RawBodyRequest<Request>: Injects the Express request object, typed with RawBodyRequest to indicate req.rawBody is available thanks to the rawBody: true option used in main.ts.
  • @Res() res: Response: Injects the Express response object, allowing us to send custom status codes and bodies.
  • Raw Body Access: We access req.rawBody, which contains the raw request payload as a Buffer.
  • Validation: Calls webhookService.validateSignature() with headers and the raw body.
  • Error Handling: Sends 400 Bad Request if raw body is missing or JSON parsing fails, and 403 Forbidden if the signature is invalid.
  • Asynchronous Processing: Calls webhookService.processCallback() without await. This allows the controller to send the 200 OK response back to Sinch immediately after validation, acknowledging receipt. Actual processing happens in the background. Errors during processing are logged but don't prevent the acknowledgment.
  • Immediate Response: Sending res.status(HttpStatus.OK).send(...) is critical. Sinch expects a 2xx response quickly; otherwise, it assumes the delivery failed and will retry according to its policy.

3. Implement the Webhook Service:

The service contains the core logic for validating the signature and processing the callback payload.

typescript
// src/webhook/webhook.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto'; // Import Node.js crypto module

// TODO: Define specific TypeScript interfaces for Sinch callback payloads
//       instead of using `any` for better type safety and clarity.
//       e.g., interface SinchMessageDeliveryReport { ... }
//       Refer to the official Sinch Conversation API documentation for payload structures.

@Injectable()
export class WebhookService {
  private readonly logger = new Logger(WebhookService.name);
  private readonly sinchWebhookSecret: string;

  constructor(private readonly configService: ConfigService) {
    this.sinchWebhookSecret = this.configService.get<string>(
      'SINCH_WEBHOOK_SECRET',
    );
    if (!this.sinchWebhookSecret) {
      this.logger.error(
        'SINCH_WEBHOOK_SECRET is not configured in environment variables.',
      );
      // Optional: Throw an error during startup if the secret is missing
      // throw new Error('Missing SINCH_WEBHOOK_SECRET environment variable');
    }
  }

  /**
   * Validates the HMAC signature of an incoming Sinch webhook request.
   * IMPORTANT: Verify the exact format (separator, order) of the signedData
   * string against the latest official Sinch documentation.
   * @param headers The request headers object.
   * @param rawBody The raw request body buffer.
   * @returns True if the signature is valid, false otherwise.
   */
  validateSignature(headers: Record<string, string>, rawBody: Buffer): boolean {
    if (!this.sinchWebhookSecret) {
      this.logger.error(
        'Cannot validate signature: SINCH_WEBHOOK_SECRET is not set.',
      );
      return false; // Cannot validate without a secret
    }

    const timestamp = headers['x-sinch-webhook-signature-timestamp'];
    const nonce = headers['x-sinch-webhook-signature-nonce'];
    const algorithm = headers['x-sinch-webhook-signature-algorithm']; // Should be HmacSHA256
    const receivedSignature = headers['x-sinch-webhook-signature'];

    if (!timestamp || !nonce || !algorithm || !receivedSignature) {
      this.logger.warn(
        'Missing required signature headers for validation.',
        { timestamp, nonce, algorithm, hasSignature: !!receivedSignature },
      );
      return false;
    }

    if (algorithm !== 'HmacSHA256') {
      this.logger.warn(`Unsupported signature algorithm: ${algorithm}`);
      return false;
    }

    try {
      // Construct the string to sign: rawBodyString.nonce.timestamp
      // CRITICAL: Ensure this format matches Sinch's specification exactly.
      const signedData = rawBody.toString('utf-8') + '.' + nonce + '.' + timestamp;

      // Calculate the expected signature
      const hmac = crypto.createHmac('sha256', this.sinchWebhookSecret);
      const expectedSignatureBuffer = hmac.update(signedData).digest();
      const expectedSignatureBase64 = expectedSignatureBuffer.toString('base64');

      // Compare the signatures using a timing-safe method
      const receivedSignatureBuffer = Buffer.from(receivedSignature, 'base64');

      // Ensure buffers have the same length before comparing
      if (expectedSignatureBuffer.length !== receivedSignatureBuffer.length) {
         this.logger.warn(`Signature length mismatch.`);
         return false;
      }

      const isValid = crypto.timingSafeEqual(
        expectedSignatureBuffer,
        receivedSignatureBuffer,
      );

      if (!isValid) {
        this.logger.warn(
            `Signature mismatch. Received: ${receivedSignature}, Expected: ${expectedSignatureBase64}`,
        );
      }

      return isValid;

    } catch (error) {
      this.logger.error(
        `Error during signature validation: ${error.message}`,
        error.stack,
      );
      return false;
    }
  }

  /**
   * Processes the parsed JSON payload from a validated Sinch callback.
   * @param payload The parsed JSON object from the webhook. Using `any` for now,
   *                but defining specific interfaces is recommended (see TODO above).
   */
  async processCallback(payload: any): Promise<void> {
    this.logger.log(
      `Processing validated callback for App ID: ${payload.app_id}`,
    );

    // Log the entire payload for debugging (consider sampling in production)
    // this.logger.debug('Full Payload:', JSON.stringify(payload, null, 2));

    // Determine the event type based on payload structure
    // Sinch uses different top-level keys for different events
    if (payload.message_delivery_report) {
      this.handleMessageDeliveryReport(payload);
    } else if (payload.message) {
      // Handles MESSAGE_INBOUND. Note: MESSAGE_REDACTION trigger might have a similar
      // structure or require a different check (e.g., payload.message_redaction).
      // Verify structure based on Sinch docs for the triggers you enable.
      this.handleInboundMessage(payload);
    } else if (payload.event) {
       // Handles EVENT_INBOUND (e.g., composing). Note: CONTACT_MESSAGE_EVENT trigger
       // might have a similar structure or require a different check.
       // Verify structure based on Sinch docs for the triggers you enable.
      this.handleInboundEvent(payload);
    } else if (payload.contact_create_notification) {
      this.handleContactCreate(payload);
    } // ... add handlers for other event types like CONTACT_MERGE_NOTIFICATION, etc.
      else {
      this.logger.warn(
        `Received unknown or unhandled callback type. Keys: ${Object.keys(payload).join(', ')}`,
      );
    }

    // Add further processing logic here (e.g., update database, trigger actions)
  }

  // --- Specific Event Handlers ---

  private handleMessageDeliveryReport(payload: any): void {
    const report = payload.message_delivery_report;
    this.logger.log(
      `Handling Message Delivery Report: Msg ID [${report.message_id}], Status [${report.status}], Conv ID [${report.conversation_id}]`,
    );
    // Example: Update message status in your database
    // db.updateMessageStatus(report.message_id, report.status);
  }

  private handleInboundMessage(payload: any): void {
    // Assumes payload.message exists (for MESSAGE_INBOUND trigger)
    const message = payload.message;
    const contactMsg = message.contact_message;
    let textContent = '[Non-Text Message]';

    if (contactMsg.text_message) {
      textContent = contactMsg.text_message.text;
    } else if (contactMsg.media_message) {
      textContent = `[Media: ${contactMsg.media_message.url}]`;
    } else if (contactMsg.choice_response_message) {
        textContent = `[Choice Response: ${contactMsg.choice_response_message.postback_data}]`;
    } else if (contactMsg.location_message) {
        textContent = `[Location: ${contactMsg.location_message.title} (${contactMsg.location_message.coordinates.latitude}, ${contactMsg.location_message.coordinates.longitude})]`;
    } // ... add handling for other types like card_message, fallback_message etc.

    this.logger.log(
      `Handling Inbound Message: Msg ID [${message.id}], Contact [${message.contact_id}], Conv ID [${message.conversation_id}], Text: ""${textContent}""`,
    );
    // Example: Store the inbound message, trigger a response flow
    // db.storeInboundMessage(message.id, message.contact_id, textContent);
  }

  private handleInboundEvent(payload: any): void {
    // Assumes payload.event exists (for EVENT_INBOUND trigger)
    const event = payload.event;
     if (event?.contact_event?.composing_event) {
        const state = event.contact_event.composing_event.action === 'START_COMPOSING' ? 'Start' : 'Stop';
        this.logger.log(`Handling Inbound Event: Composing ${state} - Contact [${payload.contact_id}], Conv ID [${payload.conversation_id}]`);
     } else if (event?.contact_event?.generic_event) {
         this.logger.log(`Handling Inbound Event: Generic Event - Contact [${payload.contact_id}], Type [${event.contact_event.generic_event.type}]`);
     } else if (event?.contact_message_event?.read_receipt_event) {
         const receiptType = event.contact_message_event.read_receipt_event.receipt_type; // e.g., READ, DELIVERED
         const messageId = event.contact_message_event.read_receipt_event.message_id;
         this.logger.log(`Handling Inbound Event: Read Receipt - Contact [${payload.contact_id}], Type [${receiptType}], Msg ID [${messageId}]`);
     }
     // ... Handle other event types like unsupported_content_event if applicable
     else {
        this.logger.log(`Handling Inbound Event: Unknown Type - Contact [${payload.contact_id}], Conv ID [${payload.conversation_id}]`);
     }
    // Example: Update UI to show typing indicator or message read status
  }

   private handleContactCreate(payload: any): void {
        const contact = payload.contact_create_notification.contact;
        this.logger.log(`Handling Contact Create: Contact ID [${contact.id}], Display Name [${contact.display_name || 'N/A'}]`);
        // Example: Add contact to your CRM or database
    }

  // Add more private handler methods for other triggers as needed...
}

Explanation:

  • Constructor: Injects ConfigService and retrieves the SINCH_WEBHOOK_SECRET from environment variables. It includes a crucial check to ensure the secret is set.
  • Type Safety Note: A comment is added reminding the developer to replace any with specific TypeScript interfaces for better code quality.
  • validateSignature:
    • Retrieves necessary headers (x-sinch-webhook-signature-*).
    • Checks for missing headers or unsupported algorithms.
    • Constructs the signedData string: rawBodyString.nonce.timestamp. A critical note is added here emphasizing the need to verify this exact format against official Sinch documentation.
    • Calculates the expected HMAC-SHA256 signature using the sinchWebhookSecret.
    • Compares the calculated signature (Base64 encoded) with the received signature (x-sinch-webhook-signature) using crypto.timingSafeEqual to prevent timing attacks. This is a critical security measure.
    • Logs warnings or errors for debugging validation issues.
  • processCallback:
    • Logs the reception of a validated callback.
    • Uses a simple if/else if structure based on the presence of key fields in the payload (message_delivery_report, message, event, contact_create_notification, etc.) to determine the callback type. This aligns with the structure described in the Sinch documentation. Comments are added to clarify potential overlaps or alternative keys (like message_redaction) depending on the specific trigger used.
    • Calls specific private handler methods for each relevant event type (e.g., handleMessageDeliveryReport, handleInboundMessage).
    • Includes a warning for unhandled callback types.
  • Specific Handlers: Placeholder methods (handleMessageDeliveryReport, etc.) demonstrate how to extract relevant information for different event types and log it. Comments within these handlers are now more specific, listing examples of message types (media, choice, location) or event types (composing, read receipts) that might need handling. This is where you would add your application-specific logic – updating databases, sending notifications, triggering workflows, etc.

3. Configuring the Sinch Webhook

Your NestJS application is ready to receive callbacks, but you need to tell Sinch where to send them and what events to send.

1. Get Your Public Webhook URL:

  • Local Development: Start your NestJS application (npm run start:dev or yarn start:dev). Then, use ngrok to expose your local port (e.g., 3000 as defined in .env) to the internet:
    bash
    ngrok http 3000
    ngrok will provide a public HTTPS URL (e.g., https://<random_string>.ngrok-free.app). Copy this HTTPS URL. Your webhook endpoint will be this URL + /webhook (e.g., https://<random_string>.ngrok-free.app/webhook).
  • Production/Staging: Use the actual public HTTPS URL where your NestJS application is deployed (e.g., https://yourapi.yourdomain.com/webhook).

2. Configure Webhook in Sinch Portal:

  • Log in to your Sinch account dashboard.
  • Navigate to Conversation -> Apps.
  • Select the App you are using for sending/receiving messages.
  • Go to the Webhooks section within the App settings.
  • Click Add Webhook or edit an existing one.
  • Fill in the details:
    • Target: Paste your public HTTPS webhook URL (including the /webhook path) obtained in the previous step.
    • Target Type: Select HTTP.
    • Secret: Enter the exact same secret you defined in your .env file (SINCH_WEBHOOK_SECRET). This is crucial for HMAC validation. You can generate a strong random string for this.
    • Triggers: Select the events you want Sinch to send to this webhook. For this guide, select at least:
      • MESSAGE_DELIVERY (for delivery receipts)
      • MESSAGE_INBOUND (for messages sent by users to your app)
      • You can select others like EVENT_INBOUND (composing, read receipts), CONTACT_CREATE_NOTIFICATION, etc., based on your needs. Refer to the Sinch documentation for a full list.
  • Save the webhook configuration.

(Alternative) Configure Webhook via API:

You can also create webhooks programmatically using the Sinch Webhook Management API endpoint (/v1/projects/{{PROJECT_ID}}/webhooks). This requires a valid Sinch ACCESS_TOKEN, which is typically obtained using the API credentials mentioned in the prerequisites (e.g., Service Plan ID and Token). Note: The base URL region (eu, us, apse, etc.) must match your project's region.

bash
# Replace placeholders with your actual values
PROJECT_ID=""<your_sinch_project_id>""
APP_ID=""<your_sinch_app_id>""
WEBHOOK_URL=""<your_public_https_webhook_url/webhook>"" # e.g., ngrok URL
ACCESS_TOKEN=""<your_sinch_api_access_token>"" # Obtain using your API credentials
WEBHOOK_SECRET=""<your_strong_sinch_webhook_secret_here>"" # Must match .env

# Example using EU region URL - change if needed (e.g., us.conversation.api.sinch.com)
curl -X POST \
  ""https://eu.conversation.api.sinch.com/v1/projects/${PROJECT_ID}/webhooks"" \
  -H 'Content-Type: application/json' \
  -H ""Authorization: Bearer ${ACCESS_TOKEN}"" \
  -d '{
        ""app_id"": ""'""${APP_ID}""'"",
        ""target"": ""'""${WEBHOOK_URL}""'"",
        ""target_type"": ""HTTP"",
        ""secret"": ""'""${WEBHOOK_SECRET}""'"",
        ""triggers"": [
          ""MESSAGE_DELIVERY"",
          ""MESSAGE_INBOUND"",
          ""EVENT_INBOUND"",
          ""CONTACT_CREATE_NOTIFICATION""
          # Add other desired triggers here
        ]
      }'

Choose the method (Portal or API) that best suits your workflow. Ensure the target URL, secret, API region, and access token are correct.

4. Verification and Testing

Let's test the setup to ensure callbacks are received and validated correctly.

1. Start Your NestJS Application:

bash
# Using npm
npm run start:dev

# Or using yarn
yarn start:dev

Ensure ngrok (if used) is running and pointing to the correct local port (3000 by default).

2. Trigger Sinch Events:

  • Trigger MESSAGE_DELIVERY: Send an outbound message from your application using the Sinch Conversation API (e.g., via curl, Postman, or the Sinch API explorer) associated with the App ID where you configured the webhook. Depending on the channel (e.g., SMS, WhatsApp), Sinch should send one or more MESSAGE_DELIVERY callbacks to your endpoint as the message progresses through QUEUED_ON_CHANNEL, DELIVERED, etc.
  • Trigger MESSAGE_INBOUND: Send a message to the phone number or channel identity associated with your Sinch App (e.g., send an SMS to your Sinch SMS number, or a message via WhatsApp to your Sinch WhatsApp number). Sinch should forward this as a MESSAGE_INBOUND callback.
  • Trigger EVENT_INBOUND: Start typing a reply on a channel like WhatsApp (if EVENT_INBOUND with composing is enabled).

3. Monitor Logs:

Observe the console output where your NestJS application is running. You should see logs similar to this upon receiving a valid callback:

log
[Nest] <PID> - <Timestamp> LOG [NestFactory] Starting Nest application...
[Nest] <PID> - <Timestamp> LOG [InstanceLoader] ConfigModule dependencies initialized <ms>
# ... other module initializations ...
[Nest] <PID> - <Timestamp> LOG [InstanceLoader] WebhookModule dependencies initialized <ms>
[Nest] <PID> - <Timestamp> LOG [InstanceLoader] AppModule dependencies initialized <ms>
[Nest] <PID> - <Timestamp> LOG [Bootstrap] Application listening on port 3000
# --- Callback Received ---
[Nest] <PID> - <Timestamp> LOG [WebhookController] Received webhook request
[Nest] <PID> - <Timestamp> LOG [WebhookController] Webhook signature validated successfully.
[Nest] <PID> - <Timestamp> LOG [WebhookService] Processing validated callback for App ID: <your_app_id>
[Nest] <PID> - <Timestamp> LOG [WebhookService] Handling Message Delivery Report: Msg ID [<message_id>], Status [<status>], Conv ID [<conversation_id>]
# OR
[Nest] <PID> - <Timestamp> LOG [WebhookService] Handling Inbound Message: Msg ID [<message_id>], Contact [<contact_id>], Conv ID [<conversation_id>], Text: ""<message_text>""
# OR
[Nest] <PID> - <Timestamp> LOG [WebhookService] Handling Inbound Event: Composing Start - Contact [<contact_id>], Conv ID [<conversation_id>]
# ... etc for other types

4. Test Invalid Signature:

  • Temporarily change the SINCH_WEBHOOK_SECRET in your .env file to something incorrect.
  • Restart your NestJS application.
  • Trigger another Sinch event.
  • Check the logs. You should now see:
log
[Nest] <PID> - <Timestamp> LOG [WebhookController] Received webhook request
[Nest] <PID> - <Timestamp> WARN [WebhookService] Signature mismatch. Received: <received_sig>, Expected: <calculated_sig>
[Nest] <PID> - <Timestamp> WARN [WebhookController] Invalid webhook signature received.

Your application should respond with HTTP 403 Forbidden. Remember to change the secret back and restart.

5. Check Sinch Portal:

The Sinch portal often provides logs or status indicators for webhook deliveries. Check the Webhooks section for your app to see if Sinch reports successful deliveries (HTTP 200) or failures (other codes, indicating potential issues with your endpoint URL, signature validation, or response time).

5. Troubleshooting and Caveats

  • No Callbacks Received:
    • Verify the webhook URL in the Sinch portal is correct and publicly accessible (check ngrok status or deployment URL). Use https:// prefix.
    • Ensure your server/firewall allows incoming POST requests to the /webhook path.
    • Check the Sinch portal webhook logs for delivery errors reported by Sinch.
    • Ensure the correct triggers are selected for the webhook in Sinch.
  • HMAC Validation Failures (403 Forbidden):
    • Double-check that the SINCH_WEBHOOK_SECRET in your .env file exactly matches the secret configured in the Sinch portal webhook settings. Regenerate/re-enter both if unsure.
    • Confirm that req.rawBody is being correctly accessed in the controller (due to rawBody: true in main.ts) and passed to the service. Ensure no other global middle

Frequently Asked Questions

How to handle Sinch callbacks with NestJS?

Set up a NestJS controller to receive POST requests at a dedicated webhook endpoint (/webhook), validate the HMAC signature using a shared secret, and process the callback data asynchronously. Ensure your controller sends a 200 OK response to Sinch immediately after validation to acknowledge receipt and prevent retries. Background processing allows your application to handle callback data without impacting response time.

What is the Sinch Conversation API?

The Sinch Conversation API provides messaging capabilities and uses webhooks for real-time notifications. Instead of inefficient polling, webhooks deliver near real-time updates about message status and user replies, allowing for responsive communication flows. This guide focuses on `MESSAGE_DELIVERY` and `MESSAGE_INBOUND` callbacks.

Why does Sinch use webhooks for callbacks?

Webhooks enable real-time communication from Sinch to your application, eliminating the need for continuous polling. This method is far more efficient and ensures your application receives timely updates on message deliveries, inbound messages, and other events, enabling you to build more responsive applications.

When should I use ngrok with Sinch webhooks?

ngrok is essential during local development to create a publicly accessible URL for your webhook endpoint. Since Sinch needs to send callbacks to a public URL, ngrok acts as a tunnel, enabling local testing before deployment. In production, use your server's public HTTPS URL.

Can I configure Sinch webhooks via the API?

Yes, you can use the Sinch Webhook Management API to create or modify webhooks programmatically. This is an alternative to using the Sinch Portal UI, and requires using API credentials (like a Service Plan ID and API Token) to generate a valid access token. The specific API endpoint varies depending on your Sinch account region (e.g., eu, us, apse).

How to validate Sinch webhook signatures in NestJS?

Retrieve the timestamp, nonce, algorithm, and signature from the x-sinch-webhook-signature-* headers. Combine the raw request body, nonce, and timestamp to create the signed data string. Calculate the HMAC-SHA256 signature of this string using your webhook secret and compare it with the received signature using a timing-safe comparison method like crypto.timingSafeEqual.

What is the purpose of the SINCH_WEBHOOK_SECRET?

The `SINCH_WEBHOOK_SECRET` is a shared secret between your application and Sinch, crucial for verifying the authenticity of incoming webhooks. It's used to generate and validate HMAC signatures, ensuring that callbacks originate from Sinch and haven't been tampered with. This secret should never be hardcoded and must be kept confidential.

How to expose a local NestJS server for Sinch webhooks?

Use a tool like ngrok to create a secure tunnel to your locally running NestJS server. Start your NestJS application and run 'ngrok http <port>', replacing <port> with the port your server is listening on (3000 by default). ngrok will provide a public HTTPS URL that Sinch can use to reach your webhook endpoint.

What are the triggers for Sinch Conversation API webhooks?

Sinch offers various webhook triggers including `MESSAGE_DELIVERY` for delivery receipts, `MESSAGE_INBOUND` for user replies, `EVENT_INBOUND` for events like composing indicators or read receipts, and `CONTACT_CREATE_NOTIFICATION` for new contact creations. You can select the triggers relevant to your application's needs in the Sinch portal or via the API.

How to process Sinch callback data in NestJS?

After validating the signature, parse the raw request body as JSON. Inspect the payload structure to determine the callback type and route it to the appropriate handler function. Sinch uses different top-level keys for various events, like message_delivery_report, message, and event. Asynchronous processing is recommended for handling data without delaying the 200 OK response to Sinch.

Why is a 200 OK response important for Sinch webhooks?

Sinch requires a 200 OK response (or any 2xx code) promptly after receiving a webhook. This confirms successful delivery to your endpoint. Without a 200 OK, Sinch assumes failure and will retry the webhook according to its retry policy, potentially leading to duplicate processing of the same event.

How to troubleshoot missing Sinch callbacks?

Double-check the webhook URL in the Sinch portal, verify your server's firewall settings, inspect Sinch portal logs for delivery errors, and ensure you have selected the appropriate triggers in the Sinch configuration. Check your ngrok connection if developing locally and ensure the URL you provided is still active. Any misconfiguration in the URL, secret, or triggers can prevent callbacks from being received or processed correctly.