# Pingram Webhooks

> Configure webhooks for Pingram events. Use when the user wants to receive delivery notifications, track email opens and clicks, handle bounces, or integrate with external systems.

- Skill: `pingram-io/pingram-webhooks` (Agent Skill)
- Install (CLI): `npx skillmds@latest add pingram-io/pingram-webhooks`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pingram-io/pingram-webhooks/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: pingram-io (https://skillmd.com/u/pingram-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/pingram-io/pingram-webhooks

---


# Pingram Webhooks

Receive real-time notifications about your messages via webhooks. Track delivery, opens, clicks, bounces, and more.

## Setting Up Webhooks

1. Go to **Settings > Webhooks** in the Pingram dashboard
2. Click **Add Webhook**
3. Enter your endpoint URL (must be HTTPS)
4. Select the events you want to receive
5. Save and test

## Event Types

### Email Events

| Event Type          | Description                           |
| ------------------- | ------------------------------------- |
| `EMAIL_DELIVERED`   | Email delivered to recipient's server |
| `EMAIL_FAILED`      | Email delivery failed                 |
| `EMAIL_OPEN`        | Recipient opened the email            |
| `EMAIL_CLICK`       | Recipient clicked a link              |
| `EMAIL_UNSUBSCRIBE` | Recipient unsubscribed                |
| `EMAIL_INBOUND`     | Received an inbound email             |

### SMS Events

| Event Type        | Description                                               |
| ----------------- | --------------------------------------------------------- |
| `SMS_DELIVERED`   | SMS delivered to recipient                                |
| `SMS_FAILED`      | SMS delivery failed                                       |
| `SMS_UNSUBSCRIBE` | Recipient unsubscribed                                    |
| `SMS_INBOUND`     | Received an inbound SMS (paid accounts, dedicated number) |

### Voice Events

| Event Type         | Description            |
| ------------------ | ---------------------- |
| `CALL_FAILED`      | Voice call failed      |
| `CALL_UNSUBSCRIBE` | Recipient unsubscribed |

### Push Events

| Event Type             | Description                 |
| ---------------------- | --------------------------- |
| `PUSH_FAILED`          | Mobile push delivery failed |
| `PUSH_UNSUBSCRIBE`     | Recipient unsubscribed      |
| `WEB_PUSH_FAILED`      | Web push delivery failed    |
| `WEB_PUSH_UNSUBSCRIBE` | Recipient unsubscribed      |

### Other Events

| Event Type              | Description            |
| ----------------------- | ---------------------- |
| `INAPP_WEB_FAILED`      | In-app message failed  |
| `INAPP_WEB_UNSUBSCRIBE` | Recipient unsubscribed |
| `SLACK_FAILED`          | Slack message failed   |
| `SLACK_UNSUBSCRIBE`     | Recipient unsubscribed |

> **Note:** Inbound events (`EMAIL_INBOUND`, `SMS_INBOUND`) have a different payload structure. See the pingram-inbound skill for details.

## Webhook Payload Structure

All webhook payloads use a flat structure with the following fields:

```json
{
  "eventType": "EMAIL_DELIVERED",
  "trackingId": "019abc12-3456-7890-abcd-ef1234567890",
  "notificationId": "welcome_email",
  "channel": "EMAIL",
  "userId": "user@example.com"
}
```

| Field             | Type   | Description                                                      |
| ----------------- | ------ | ---------------------------------------------------------------- |
| `eventType`       | string | The event type (see Event Types above)                           |
| `trackingId`      | string | Unique ID for the notification (optional)                        |
| `notificationId`  | string | The notification type                                            |
| `channel`         | string | `EMAIL`, `SMS`, `CALL`, `PUSH`, `WEB_PUSH`, `INAPP_WEB`, `SLACK` |
| `userId`          | string | The recipient's user ID                                          |
| `failureCode`     | string | Error code (only for `*_FAILED` events)                          |
| `clickedLink`     | string | URL clicked (only for `EMAIL_CLICK`)                             |
| `clickedLinkTags` | object | Link tags (only for `EMAIL_CLICK`)                               |

> **Note:** Inbound events (`EMAIL_INBOUND`, `SMS_INBOUND`) have a different payload structure with fields like `from`, `to`, `text`/`bodyText`, `receivedAt`, etc. See the [pingram-inbound](./pingram-inbound/SKILL.md) skill for detailed payload examples.

## Event Payloads

### EMAIL_DELIVERED

```json
{
  "eventType": "EMAIL_DELIVERED",
  "trackingId": "019abc12-3456-7890-abcd-ef1234567890",
  "notificationId": "welcome",
  "channel": "EMAIL",
  "userId": "user@example.com"
}
```

### EMAIL_FAILED

```json
{
  "eventType": "EMAIL_FAILED",
  "trackingId": "019abc12-3456-7890-abcd-ef1234567890",
  "notificationId": "welcome",
  "channel": "EMAIL",
  "userId": "user@example.com",
  "failureCode": "EMAIL_BOUNCE"
}
```

### EMAIL_OPEN

```json
{
  "eventType": "EMAIL_OPEN",
  "trackingId": "019abc12-3456-7890-abcd-ef1234567890",
  "notificationId": "welcome",
  "channel": "EMAIL",
  "userId": "user@example.com"
}
```

### EMAIL_CLICK

```json
{
  "eventType": "EMAIL_CLICK",
  "trackingId": "019abc12-3456-7890-abcd-ef1234567890",
  "notificationId": "welcome",
  "channel": "EMAIL",
  "userId": "user@example.com",
  "clickedLink": "https://app.example.com/activate",
  "clickedLinkTags": {
    "campaign": ["onboarding"],
    "action": ["activate"]
  }
}
```

### SMS_DELIVERED

```json
{
  "eventType": "SMS_DELIVERED",
  "trackingId": "019abc12-3456-7890-abcd-ef1234567890",
  "notificationId": "otp",
  "channel": "SMS",
  "userId": "user@example.com"
}
```

### SMS_FAILED

```json
{
  "eventType": "SMS_FAILED",
  "trackingId": "019abc12-3456-7890-abcd-ef1234567890",
  "notificationId": "otp",
  "channel": "SMS",
  "userId": "user@example.com",
  "failureCode": "INVALID_NUMBER"
}
```

### CALL_FAILED

```json
{
  "eventType": "CALL_FAILED",
  "trackingId": "019abc12-3456-7890-abcd-ef1234567890",
  "notificationId": "alert",
  "channel": "CALL",
  "userId": "user@example.com",
  "failureCode": "NO_ANSWER"
}
```

### \*\_UNSUBSCRIBE

All unsubscribe events follow this format:

```json
{
  "eventType": "EMAIL_UNSUBSCRIBE",
  "notificationId": "marketing",
  "channel": "EMAIL",
  "userId": "user@example.com"
}
```

## Webhook Handler Example

```typescript
import express from 'express';

const app = express();
app.use(express.json());

app.post('/webhooks/pingram', (req, res) => {
  const {
    eventType,
    trackingId,
    notificationId,
    channel,
    userId,
    failureCode,
    clickedLink
  } = req.body;

  switch (eventType) {
    case 'EMAIL_DELIVERED':
      console.log(`Email delivered to ${userId}`);
      // Update delivery status in your database
      break;

    case 'EMAIL_FAILED':
      console.log(`Email failed: ${failureCode}`);
      // Handle bounce, mark email as invalid if hard bounce
      break;

    case 'EMAIL_OPEN':
      console.log(`Email opened by ${userId}`);
      // Track engagement metrics
      break;

    case 'EMAIL_CLICK':
      console.log(`Link clicked: ${clickedLink}`);
      // Track click-through rates
      break;

    case 'SMS_DELIVERED':
      console.log(`SMS delivered to ${userId}`);
      break;

    case 'SMS_FAILED':
      console.log(`SMS failed: ${failureCode}`);
      break;

    case 'EMAIL_UNSUBSCRIBE':
    case 'SMS_UNSUBSCRIBE':
      console.log(`${userId} unsubscribed from ${channel}`);
      // Update user preferences
      break;

    default:
      console.log(`Unhandled event: ${eventType}`);
  }

  // Always respond quickly
  res.status(200).send('OK');
});

app.listen(3000);
```

## Best Practices

### 1. Respond Quickly

```typescript
app.post('/webhooks/pingram', async (req, res) => {
  // Respond immediately
  res.status(200).send('OK');

  // Process asynchronously
  processWebhookAsync(req.body).catch(console.error);
});
```

### 2. Handle Duplicates

Use `trackingId` for idempotency:

```typescript
app.post('/webhooks/pingram', async (req, res) => {
  const { eventType, trackingId } = req.body;
  const eventKey = `${eventType}:${trackingId}`;

  // Check if already processed
  if (await redis.exists(eventKey)) {
    return res.status(200).send('Already processed');
  }

  // Mark as processing
  await redis.setex(eventKey, 86400, '1');

  // Process event...
  res.status(200).send('OK');
});
```

### 3. Use Queues for Heavy Processing

```typescript
import { Queue } from 'bullmq';

const webhookQueue = new Queue('webhooks');

app.post('/webhooks/pingram', async (req, res) => {
  // Add to queue for processing
  await webhookQueue.add('process', req.body);
  res.status(200).send('OK');
});
```

## Testing Webhooks

### Using the Dashboard

1. Go to Settings > Webhooks
2. Click "Test" next to your webhook
3. Select an event type
4. Send test payload

### Local Development

Use a tunneling service like ngrok:

```bash
ngrok http 3000
```

Then use the ngrok URL as your webhook endpoint.

## Common Issues

**Not receiving webhooks:**

- Verify URL is correct and HTTPS
- Check firewall allows incoming connections
- Ensure your server returns 2xx status

**Duplicate events:**

- Implement idempotency using trackingId
- Store processed event IDs temporarily

**Webhook delays:**

- Check your server response time
- Ensure you're responding with 200 quickly

