Skip to main content

Drum webhooks

Subscribe to Drum events including project.completed, verify signatures, handle retries, combined updates, and timestamp precision.

Written by Ben Walker

Webhooks allow you to receive real-time HTTP notifications when events occur in Drum. Instead of polling the API for changes, you can subscribe to specific events and Drum will push data to your endpoint automatically.

Getting Started

Creating a Webhook Subscription

  1. Navigate to Settings > Webhooks in Drum

  2. Click New Webhook

  3. Enter a name for your subscription (e.g., "Accounting System Sync")

  4. Enter your HTTPS endpoint URL

  5. Select the events you want to receive

  6. Click Create

Drum will generate a unique secret key for signature verification. Copy and store this securely - you'll need it to verify incoming webhooks.

Requirements

  • HTTPS Required: Your endpoint must use HTTPS with a valid SSL certificate

  • Public URL: Private/internal IP addresses (10.x.x.x, 192.168.x.x, localhost) are blocked

  • Response Time: Respond within 30 seconds with a 2xx status code

Available Events

Event

Description

project.created

A new project was created

project.updated

An existing project was modified

project.completed

A delivery project moved from a status that is not complete to a complete status

invoice.created

A new invoice was created

invoice.updated

An existing invoice was modified

cost.created

A new cost was recorded

cost.updated

An existing cost was modified

opportunity.won

An opportunity moved to a won status (its deal_won_date was set)

opportunity.lost

An opportunity moved to a lost status (its deal_lost_date was set)

Opportunities are stored as projects with project_type: "opportunity", so project.created and project.updated fire for opportunities as well as for delivery projects. Use the project_type attribute in the payload to tell them apart.

project.completed fires for delivery projects only (project_type: "project"), in the same save as project.updated. It fires once for each move into a complete status: a move between two complete statuses, or an edit of completed_date, does not fire it again. Moving the project back to a status that is not complete clears completed_date and fires only project.updated. Opportunities never fire project.completed.

Completion events are sent after the transaction commits and contain the state at the completion transition. A later save in that transaction does not erase the event or change its payload. A rolled-back transition sends no event. Updates made while creating a project are included in project.created instead of separate update or completion events.

opportunity.won and opportunity.lost fire once per outcome: the first time the relevant date moves from empty to set. Moving a won opportunity back to a standard status clears the date and fires only project.updated; a later move back to won fires opportunity.won again.

Converting an opportunity into a project emits three events: project.created for the new project, plus project.updated and opportunity.won for the opportunity. Delivery order is not guaranteed. Correlate them with the opportunity_id attribute on the new project, which holds the id of the opportunity it was converted from.

Update events are combined

Drum groups the .updated events of one record (project.updated, invoice.updated, cost.updated) over a 5-second window:

  • The first update in a window is sent at once.

  • If the record changes again in the window, Drum sends one more .updated event about 5 seconds later. It has the state of the record at that time, and it starts a new window.

You do not always get one event for each save, but after a burst of changes Drum sends the state of the record when the burst ends. Retries and network delays can deliver events out of order, so keep the event with the latest data.attributes.updated_at for each record. A save that changes only the record's timestamp (for example, when a booking or milestone on the project changes) sends no event. .created events, project.completed, opportunity.won and opportunity.lost are not combined.

Webhook timestamps include six fractional-second digits, so distinct saves within the same second can be ordered by data.attributes.updated_at. Date-only attributes such as completed_date remain YYYY-MM-DD.

A budget spreadsheet import into a project sends one project.updated when it finishes, with the new totals. It does not send an event for each deliverable or task that it creates.

Duplicate Project does not emit project.created (or other project webhooks). Event Triggers for Project created do fire on duplicate; webhooks do not. See How to duplicate a project.

Webhook Payload

All webhooks are sent as HTTP POST requests with a JSON body.

Headers

Header

Description

Content-Type

Always application/json

X-Webhook-Event

The event type (e.g., project.created)

X-Webhook-Signature

HMAC-SHA256 signature for verification

X-Webhook-Timestamp

Unix timestamp when the webhook was sent

X-Webhook-Delivery-Id

Unique ID for this delivery attempt

Payload Structure

{
"event": "project.created",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"id": 12345,
"type": "Project",
"attributes": {
// Event-specific attributes
}
}
}

Event Payloads

Project Events

project.created, project.updated, project.completed, opportunity.won, and opportunity.lost all use the project payload and include these attributes:

{
"event": "project.created",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"id": 12345,
"type": "Project",
"attributes": {
"id": 12345,
"name": "Office Renovation",
"project_number": "PRJ-2025-001",
"project_type": "project",
"start_date": "2025-01-15",
"end_date": "2025-06-30",
"completed_date": null,
"currency": "AUD",
"total_budget_cents": 15000000,
"total_invoiced_cents": 5000000,
"costs_to_date_cents": 3500000,
"team_id": 42,
"client_id": 789,
"status": "In Progress",
"complete_status": false,
"client_name": "Acme Corporation",
"team_name": "Engineering",
"opportunity_id": 12001,
"opportunity_number": "OPP-2025-014",
"deal_won_date": null,
"deal_lost_date": null,
"lead_score": null,
"sales_status_type": "standard",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
}
}

Attribute

Description

status

The name of the current status, or null

complete_status

true when the current status is a complete status, otherwise false (null when the project has no status)

completed_date

The date the project moved into a complete status, or null. Drum clears it when the project moves back to a status that is not complete

opportunity_id

For a project converted from an opportunity, the id of that opportunity; otherwise null

opportunity_number

The project number of the source opportunity, or null

deal_won_date

When the opportunity was marked won, or null

deal_lost_date

When the opportunity was marked lost, or null

lead_score

The name of the opportunity's lead score, or null

sales_status_type

The type of the current status: standard, won, or lost

The payload never includes the opportunity's estimated amount, final deal amount, or estimated deposit.

Invoice Events

invoice.created and invoice.updated include these attributes:

{
"event": "invoice.created",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"id": 5678,
"type": "Invoice",
"attributes": {
"id": 5678,
"invoice_number": "INV-2025-001",
"project_id": 12345,
"project_number": "PRJ-2025-001",
"status": "sent",
"currency": "AUD",
"total_cents": 550000,
"total_excluding_tax_cents": 500000,
"paid_date": null,
"issued_date": "2025-01-15",
"due_date": "2025-02-14",
"amount_paid_cents": 0,
"reference": "Progress Claim #1",
"description": "January progress claim",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
}
}

Cost Events

cost.created and cost.updated include these attributes:

{
"event": "cost.created",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"id": 9012,
"type": "Cost",
"attributes": {
"id": 9012,
"name": "Steel materials",
"project_id": 12345,
"project_number": "PRJ-2025-001",
"task_id": 456,
"status": "approved",
"billing_status": "billable",
"currency": "AUD",
"total_cents": 110000,
"total_excluding_tax_cents": 100000,
"issued_date": "2025-01-10",
"due_date": "2025-02-10",
"paid_date": null,
"invoice_number": "SUP-12345",
"reference": "PO-2025-042",
"description": "Structural steel for level 2",
"supplier_id": 789,
"supplier_type": "Company",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-15T10:30:00Z"
}
}
}

Security

Verifying Webhook Signatures

Every webhook includes an HMAC-SHA256 signature in the X-Webhook-Signature header. You should always verify this signature to ensure the webhook came from Drum and hasn't been tampered with.

The signature is computed as:

signature = HMAC-SHA256(timestamp + "." + payload, secret)
header = "sha256=" + signature

Ruby

def verify_webhook(request, secret)
signature = request.headers["X-Webhook-Signature"]
timestamp = request.headers["X-Webhook-Timestamp"]
payload = request.raw_post
​
expected_signature = "sha256=" + OpenSSL::HMAC.hexdigest(
"sha256",
secret,
[timestamp, payload].join(".")
)
​
ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature)
end

Python

import hmac
import hashlib
​
def verify_webhook(headers, body, secret):
signature = headers.get("X-Webhook-Signature")
timestamp = headers.get("X-Webhook-Timestamp")
​
signed_payload = f"{timestamp}.{body}"
expected = "sha256=" + hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
​
return hmac.compare_digest(signature, expected)

Node.js

const crypto = require('crypto');
​
function verifyWebhook(headers, body, secret) {
const signature = headers['x-webhook-signature'];
const timestamp = headers['x-webhook-timestamp'];
​
const signedPayload = `${timestamp}.${body}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
​
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}

Preventing Replay Attacks

Check the X-Webhook-Timestamp header and reject webhooks older than a few minutes:

def webhook_fresh?(timestamp, tolerance: 5.minutes)
webhook_time = Time.at(timestamp.to_i)
webhook_time > tolerance.ago
end

Retry Behavior

If your endpoint returns a non-2xx response or times out, Drum will retry the delivery:

  • Attempts: Up to 5 retries with increasing delays

  • Backoff: Polynomial backoff (delays increase with each retry)

  • Timeout: 30 seconds per request

After 10 consecutive failures across all deliveries, your webhook subscription will be automatically disabled to prevent resource waste. You'll receive an email notification when this happens.

Best Practices

Respond Quickly

Return a 2xx response as soon as you receive the webhook. Process the data asynchronously if needed:

def webhooks
# Verify signature first
return head :unauthorized unless verify_webhook(request, ENV["DRUM_WEBHOOK_SECRET"])
​
# Queue for async processing
WebhookProcessorJob.perform_later(params.to_json)
​
head :ok
end

Handle Duplicates

Webhooks may be delivered more than once. Use the X-Webhook-Delivery-Id header to deduplicate:

def process_webhook(delivery_id, payload)
return if ProcessedWebhook.exists?(delivery_id: delivery_id)
​
ProcessedWebhook.create!(delivery_id: delivery_id)
# Process the webhook...
end

Store Raw Payloads

Store the raw webhook payload before processing. This helps with debugging and reprocessing if needed.

Managing Subscriptions

Pausing and Resuming

You can pause a webhook subscription from the Drum UI. Paused subscriptions don't receive events but retain their configuration.

Regenerating Secrets

If your secret is compromised, regenerate it from the subscription settings. Update your endpoint with the new secret immediately - the old secret will stop working.

Viewing Delivery History

Each subscription shows recent delivery attempts with:

  • Status (success/failed)

  • Response code

  • Error message (if failed)

  • Timestamp

You can retry failed deliveries from the UI.

Troubleshooting

Webhook Not Received

  1. Check your endpoint is publicly accessible over HTTPS

  2. Verify the subscription is active (not paused or disabled)

  3. Confirm you're subscribed to the correct events

  4. Check your server logs for incoming requests

Signature Verification Failing

  1. Ensure you're using the correct secret (check for copy/paste errors)

  2. Verify you're using the raw request body, not parsed JSON

  3. Confirm the timestamp and payload are concatenated correctly

  4. Check for encoding issues (use UTF-8)

Subscription Disabled

Your subscription may be auto-disabled after 10 consecutive failures. To re-enable:

  1. Fix the underlying issue with your endpoint

  2. Go to Settings > Webhooks

  3. Click on the disabled subscription

  4. Click Resume

The failure counter resets on the next successful delivery.

Timeout Errors

If you're seeing timeout errors:

  • Ensure your endpoint responds within 30 seconds

  • Return 200 immediately and process asynchronously

  • Check for network issues between Drum and your server

Did this answer your question?