๐Ÿ‘‘ Webhooks

Jelly’s webhook integration allows teams to receive real-time notifications about events by sending JSON payloads to a configured webhook URL. You can subscribe to specific event types and verify payloads using HMAC signatures.

This feature is only available on the Royal Jelly plan

Webhook Formats

Jelly supports two webhook payload formats:

New webhooks use the structured format by default. This format provides:

  • Event subscriptions: Choose which events trigger webhooks
  • HMAC payload signing: Verify webhook authenticity
  • Consistent JSON structure: Predictable payload format across all events
  • Additional event types: Beyond just new messages

Legacy Format

Existing webhooks created before the structured format was introduced use the legacy format. Legacy webhooks:

  • Only receive new_message events
  • Use a flat JSON structure (documented below for backwards compatibility)
  • Do not support HMAC signing

You can upgrade a legacy webhook to the structured format from the Integrations settings page.

Configuration

Webhooks can be configured at the team level in Settings โ†’ Integrations with the following options:

  • URL: The HTTPS endpoint to receive the webhook payload (required)
  • Basic Authentication (optional):
    • Username
    • Password
  • Event Subscriptions (structured format only): Select which events trigger webhooks
  • Signing Secret (structured format only): Auto-generated secret for HMAC verification

Structured Payload Format

All structured webhook payloads follow this envelope format:

{
  "event": "event_type",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    // Event-specific data
  }
}

Event Types

new_message

Triggered when a new message is sent or received.

{
  "event": "new_message",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "message": {
      "id": "abc123",
      "subject": "Re: Your inquiry",
      "sent_at": "2024-01-15T10:30:00Z",
      "from": ["sender@example.com"],
      "to": ["recipient@example.com"],
      "cc": [],
      "text_body": "Plain text content",
      "html_body": "<p>HTML content</p>",
      "inbound": true
    },
    "conversation": {
      "id": "conversation-slug",
      "subject": "Your inquiry",
      "status": "open",
      "labels": [
        {"id": "label-id", "name": "Support", "color": "#ff0000"}
      ]
    },
    "is_new_conversation": false
  }
}

assigned

Triggered when a conversation is assigned to a team member.

{
  "event": "assigned",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "assignee": {
      "id": "member-id",
      "name": "John Doe",
      "email": "john@example.com"
    },
    "conversation": {
      "id": "conversation-slug",
      "subject": "Customer question",
      "status": "open",
      "labels": []
    },
    "assigner": {
      "id": "assigner-id",
      "name": "Jane Smith",
      "email": "jane@example.com"
    }
  }
}

comment_added

Triggered when a team member adds an internal comment/note.

{
  "event": "comment_added",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "comment": {
      "id": "note-id",
      "body": "This customer needs priority support",
      "created_at": "2024-01-15T10:30:00Z",
      "author": {
        "id": "member-id",
        "name": "John Doe",
        "email": "john@example.com"
      }
    },
    "conversation": {
      "id": "conversation-slug",
      "subject": "Customer question",
      "status": "open",
      "labels": []
    }
  }
}

conversation_archived

Triggered when a conversation is archived/closed.

{
  "event": "conversation_archived",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "conversation": {
      "id": "conversation-slug",
      "subject": "Resolved issue",
      "status": "archived",
      "labels": []
    },
    "actor": {
      "id": "member-id",
      "name": "John Doe",
      "email": "john@example.com"
    }
  }
}

conversation_unarchived

Triggered when a conversation is reopened/unarchived.

{
  "event": "conversation_unarchived",
  "created_at": "2024-01-15T10:30:00Z",
  "data": {
    "conversation": {
      "id": "conversation-slug",
      "subject": "Follow-up needed",
      "status": "open",
      "labels": []
    },
    "actor": {
      "id": "member-id",
      "name": "John Doe",
      "email": "john@example.com"
    }
  }
}

Request Details

HTTP Method

POST

Content Type

application/json

Headers

All webhook requests include these headers:

Header Description
Content-Type application/json
X-Jelly-Event The event type (e.g., new_message)
X-Jelly-Timestamp Unix timestamp of when the request was sent
X-Jelly-Signature HMAC-SHA256 signature (structured format only)

Authentication

If basic authentication is configured, the webhook request will include an Authorization header:

Authorization: Basic [base64-encoded-credentials]

Verifying Webhook Signatures

For structured webhooks, you should verify the X-Jelly-Signature header to ensure the payload is authentic.

Verification Steps

  1. Get the raw request body (before JSON parsing)
  2. Get your signing secret from the Integrations settings page
  3. Compute the expected signature: sha256= + HMAC-SHA256(signing_secret, raw_body)
  4. Compare with the X-Jelly-Signature header

Example (Ruby)

def verify_webhook(request, signing_secret)
  signature = request.headers['X-Jelly-Signature']
  payload = request.raw_post

  expected = "sha256=" + OpenSSL::HMAC.hexdigest('SHA256', signing_secret, payload)

  ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end

Example (Node.js)

const crypto = require('crypto');

function verifyWebhook(payload, signature, signingSecret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', signingSecret)
    .update(payload, 'utf8')
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

Error Handling and Retries

  • Failed Deliveries: If a webhook delivery fails (non-2xx response or timeout), the error is logged and visible in the Integrations settings
  • Automatic Deactivation: Webhooks are automatically deactivated after 10 consecutive failures over 24 hours
  • Retries: Failed webhook deliveries are not automatically retried
  • Reactivation: Deactivated webhooks can be reactivated from the Integrations settings page
  • Success Clearing: Successful deliveries automatically clear any previous error state

Security Considerations

It’s important to remember that using webhooks will send your message data outside of Jelly, so you are responsible for the security of that data.

  1. Use HTTPS URLs: Webhook URLs should use HTTPS for secure transmission
  2. Verify Signatures: Always verify the X-Jelly-Signature header for structured webhooks
  3. Use Authentication: Configure basic authentication or verify signatures for endpoint security
  4. Disable When Not Needed: If you no longer need your webhook, disable it promptly

Testing Webhooks

To test your webhook integration:

  1. Configure a webhook URL using a service like webhook.site or ngrok
  2. Send a test email to your Jelly inbox
  3. Verify the payload structure and signature
  4. Check the webhook status in your team’s Integrations settings for any errors

Integration Tips

  • Your integration should gracefully ignore any keys in the JSON response that it does not recognise
  • Do not assume fields are always present or always of a certain length
  • Email can have multiple senders, despite that seeming unusual
  • The subject of a message and conversation may differ (participants can change subjects)

Legacy webhook Payload Format

When a new message is created in a conversation, Jelly sends the following JSON payload:

{
  "url": "https://app.letsjelly.com/[team-id]/conversations/[conversation-id]#message_[message-id]",
  "subject": "Message subject line",
  "sent_at": "2024-01-15T10:30:00Z",
  "from": ["sender@example.com", "Another Sender Name <another-sender@example.com>"],
  "to": ["recipient@example.com", "Another Recipient <recipient2@example.com>"],
  "cc": ["cc@example.com"],
  "bcc": ["bcc@example.com"],
  "html_body": "<html>Message HTML content</html>",
  "text_body": "Plain text version of the message",
  "attachments_count": 2,
  "new_conversation": true,
  "conversation": {
    "id": "conversation-id",
    "url": "https://app.letsjelly.com/[team-id]/conversations/[conversation-id]",
    "subject": "Conversation subject",
    "messages_count": 5,
    "got_by": "[{\"name\":\"John Doe\",\"email\":\"john@team.com\"}]"
  }
}

Field Descriptions

Message Fields

  • url (string): Direct link to the specific message within the conversation
  • subject (string): Subject line of the message
  • sent_at (datetime): ISO 8601 timestamp when the message was sent
  • from (array): List of sender email addresses, may include display names
  • to (array): List of recipient email addresses, may include display names
  • cc (array): List of CC recipient email addresses, may include display names
  • bcc (array): List of BCC recipient email addresses, may include display names. This is only ever present on outgoing messages, as recipients are never given the contents of Bcc fields.
  • html_body (string): HTML content of the message body. This may be empty or null if the message only had a text part
  • text_body (string): Plain text version of the message body. This may be empty or null if the message only had an HTML part
  • attachments_count (integer): Number of attachments on the message
  • new_conversation (boolean): true if this is the first message in a new conversation, false for replies or follow-up messages

Conversation Fields

  • conversation.id (string): Unique identifier for the conversation – typically a parameterized version of the conversation subject. This corresponds with the URL on the website.
  • conversation.url (string): Link to view the full conversation
  • conversation.subject (string): Subject line of the conversation
  • conversation.custom_subject (string, optional): Customised subject of conversation. This may not be present.
  • conversation.messages_count (integer): Total number of messages in the conversation
  • conversation.got_by (string): JSON-encoded array of assigned users with their name and email

Legacy Webhook Triggers

Legacy webhooks are triggered when:

  • A new inbound email is received by Jelly
  • A team member sends an outbound reply
  • A team member starts a new conversation from Jelly

Legacy webhooks are NOT currently triggered for:

  • System notifications
  • Internal notes or comments
  • Assignments
  • Message status changes (archive, label, etc.)

Request Details

HTTP Method

POST

Content Type

application/json

Authentication

If basic authentication is configured, the webhook request will include an Authorization header:

Authorization: Basic [base64-encoded-credentials]

Error Handling

  • Failed Deliveries: If a webhook delivery fails (non-2xx response or timeout), the error is logged with:

    • Error message
    • Timestamp of the failure
    • These can be viewed in the webhook integration settings
  • Retries: Failed webhook deliveries are not automatically retried

  • Error Clearing: Successful deliveries automatically clear any previous error state

Integration tips

Your integration should gracefully ignore any keys in the JSON response that it does not recognise, so that future changes to the webhook response don’t cause any issues.

You should not build in any assumptions that fields are always present, or always of a certain length. Email is pretty weird, so – for example, an email can be from multiple addresses, despite that seeming nonsensical given the way email is typically used.

The subject of a message and the subject of a conversation may not always be the same - the subject of a conversation is typically set by the first message, but participants in email are free to change subjects whenever they like.

If someone customises a conversation subject in Jelly, we will also include that value, but otherwise it will be absent.