API reference / Extend the dashboard
Canvas widgets
Canvas lets you add your own panel to the Drop Cowboy® dashboard. When a
teammate opens a contact, Drop Cowboy sends a signed POST to your server with
the contact's details. Your server answers with Canvas JSON, a short list of
components such as text, badges, inputs and buttons, and the dashboard draws
it. When someone clicks one of your buttons, Drop Cowboy sends a second signed
request so your server can save the input and redraw the panel.
You host the server. Your code runs only there, and the dashboard draws your JSON as plain text, never as HTML.
How Canvas works
- You create a widget in the dashboard with a name and an HTTPS Webhook URL. Drop Cowboy gives the widget a Signing Secret.
- A teammate opens a contact. The dashboard asks Drop Cowboy to render each
widget that is switched on, and Drop Cowboy sends a
canvas.renderrequest to your URL. - Your server checks the signature and answers with Canvas JSON within 20 seconds.
- If the teammate clicks a button whose action is
submit, Drop Cowboy sends acanvas.actionrequest with the button's payload and every input value. Your server answers within 5 seconds with a new panel, a message, or both.
You create and manage widgets in the dashboard, not with API keys.
Canvas, AI widgets and report HTML
Three things in the dashboard draw custom content. Only Canvas calls your server.
| Canvas widget | AI widget (Create with AI) | Report HTML | |
|---|---|---|---|
| Who writes it | Your server, on every view | Drop Cowboy's AI, once | Drop Cowboy's AI, once |
| Format | Canvas JSON | HTML in a sandboxed frame | Sanitized HTML |
| Calls your server | Yes, signed | No | No |
| Signing secret | Yes | No | No |
| Runs scripts | No | Yes, inside the frame | No |
| Costs AI credits | No | Yes | Yes |
Use Canvas when the panel needs live data from your own system or has to write back to it.
Create a widget and get its signing secret
- Open Settings in the left menu. Under Developers, click Canvas. You need the developers permission, the same one that covers API keys and webhooks.
- Click Add Widget. The Create Canvas Widget window shows the new widget's Signing Secret. Click Copy and store it on your server.
- Type a Widget Name, an optional Description, and the Webhook URL.
It must start with
https://. Click Create. - The widget is switched on. Open a contact to see it.
To read the secret later, click Reveal or Copy next to the widget in the list. Regenerate Secret on the widget menu replaces it at once: the next request is signed with the new secret, so update your server immediately. Edit changes the name, description and URL and keeps the secret.
Where widgets appear
A widget belongs to one slot. The slot decides where it is drawn and what
context it receives.
| Slot | Where it shows | context |
|---|---|---|
contact_detail |
The contact panel in Contacts, the full contact profile, and the contact sidebar in Inbox | The contact (below) |
dashboard |
The home dashboard and the dialer, campaign and agency hubs | {} |
inbox |
The inbox | {} |
Widgets you create with Add Widget use contact_detail. On a contact,
Canvas Apps then Add App lists the team's contact_detail widgets and
switches each one on or off for the whole team.
The render request
Drop Cowboy sends POST to your Webhook URL with a JSON body.
Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
dropcowboy-canvas/1.0 |
X-Timestamp |
Unix time in seconds when the request was signed |
X-Signature |
sha256= and a hex HMAC-SHA256 of X-Timestamp, a ., and the raw body, keyed with the widget's signing secret |
X-Signature-Version |
v1 |
Body
{
"event": "canvas.render",
"widget_id": "3f6c2a1e-8d4b-4c7a-9e2f-5b1d7c3a9e40",
"slot": "contact_detail",
"context": {
"viewer_team_id": "b7e2d9c4-1a6f-4e3b-8c5d-2f9a0e7b6c13",
"viewer_user_id": "e4a1c8f2-6b3d-4f9e-a7c5-0d2b8e1f4a69",
"subject_team_id": "b7e2d9c4-1a6f-4e3b-8c5d-2f9a0e7b6c13",
"contact_id": "9d2f7b3a-4e1c-4a8b-b6d5-3c7e0f2a1b84",
"fields": {
"first_name": "Maria",
"last_name": "Lopez",
"main_phone": "+13125550142",
"email": "maria@example.com",
"company": "Lopez Roofing"
},
"lists": ["6a0e3b9d-2c7f-4d1a-8e4b-9f5c1a7d3e26"],
"tags": [],
"task": null
},
"user": {
"user_id": "e4a1c8f2-6b3d-4f9e-a7c5-0d2b8e1f4a69",
"email": "alex@example.com",
"name": "Alex Kim"
},
"team_id": "b7e2d9c4-1a6f-4e3b-8c5d-2f9a0e7b6c13",
"timestamp": "2026-09-30T15:04:05.000Z"
}
team_id and user come from the signed-in session, so you can trust them.
user.email and user.name can be null. context is built by the browser:
treat every value in it as input to check, not as proof.
Context
For contact_detail, context holds:
contact_id: the contact being viewed.fields: the standard contact fields (names, phone numbers,email, address fields,company,owner, consent flags and more) plus each custom field under its slug.lists: the ids of the lists the contact is on.tags: the contact's tags, which can be empty.viewer_team_idandviewer_user_id: who is looking.subject_team_id: the team that owns the contact, which differs from the viewer's team when an agency views a client's contact.task: in Inbox, the open conversation (task_id,task_type,status,assigned_to,assigned_type,unread,created_at). Elsewherenull.
Verify the signature
Compute the HMAC over the raw bytes you received, before any JSON parsing.
Reject the request if the signature doesn't match, or if X-Timestamp is more
than 5 minutes from your clock. This is the same scheme as
account webhooks, with the widget's own
secret. Each sample is a complete server with no dependencies. Run it behind
your HTTPS proxy with DC_CANVAS_SECRET set to the widget's signing secret.
Node.js
const crypto = require('crypto');
const http = require('http');
const SECRET = process.env.DC_CANVAS_SECRET;
const TOLERANCE_SECONDS = 300;
const NOTE_FIELD = 'note';
function verify(headers, rawBody) {
const signature = headers['x-signature'] || '';
const timestamp = headers['x-timestamp'] || '';
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
.update(timestamp + '.')
.update(rawBody)
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function renderPanel(event) {
const fields = event.context.fields || {};
return {
canvas: {
content: {
components: [
{ type: 'text', style: 'header', text: 'Account: ' + (fields.company || 'none') },
{ type: 'status_row', items: [
{ label: 'Plan', value: 'Pro' },
{ label: 'Open tickets', value: '2' }
] },
{ type: 'textarea', id: NOTE_FIELD, label: 'Add a note', rows: 2 },
{ type: 'button', label: 'Save note', style: 'primary',
action: { type: 'submit', payload: { action: 'save_note' } } }
]
}
}
};
}
function handleAction(event) {
const payload = (event.action && event.action.payload) || {};
if (payload.action !== 'save_note') {
return { toast: { type: 'error', message: 'Unknown action' } };
}
const note = String(event.input_values[NOTE_FIELD] || '').trim();
if (!note) {
return { toast: { type: 'info', message: 'Type a note first' } };
}
// Save the note for event.context.contact_id in your system here.
return { toast: { type: 'success', message: 'Note saved' } };
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
if (req.method !== 'POST' || !verify(req.headers, rawBody)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(rawBody.toString('utf8'));
const reply = event.event === 'canvas.action' ? handleAction(event) : renderPanel(event);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(reply));
});
}).listen(Number(process.env.PORT) || 3000);
Python
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["DC_CANVAS_SECRET"].encode()
TOLERANCE_SECONDS = 300
NOTE_FIELD = "note"
def verify(headers, raw_body):
signature = headers.get("X-Signature", "")
timestamp = headers.get("X-Timestamp", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
message = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.encode(), expected.encode())
def render_panel(event):
fields = event["context"].get("fields") or {}
return {"canvas": {"content": {"components": [
{"type": "text", "style": "header", "text": "Account: " + (fields.get("company") or "none")},
{"type": "status_row", "items": [
{"label": "Plan", "value": "Pro"},
{"label": "Open tickets", "value": "2"},
]},
{"type": "textarea", "id": NOTE_FIELD, "label": "Add a note", "rows": 2},
{"type": "button", "label": "Save note", "style": "primary",
"action": {"type": "submit", "payload": {"action": "save_note"}}},
]}}}
def handle_action(event):
payload = (event.get("action") or {}).get("payload") or {}
if payload.get("action") != "save_note":
return {"toast": {"type": "error", "message": "Unknown action"}}
note = str(event["input_values"].get(NOTE_FIELD) or "").strip()
if not note:
return {"toast": {"type": "info", "message": "Type a note first"}}
# Save the note for event["context"]["contact_id"] in your system here.
return {"toast": {"type": "success", "message": "Note saved"}}
class CanvasHandler(BaseHTTPRequestHandler):
def do_POST(self):
raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify(self.headers, raw_body):
self.send_response(401)
self.end_headers()
return
event = json.loads(raw_body)
reply = handle_action(event) if event["event"] == "canvas.action" else render_panel(event)
body = json.dumps(reply).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(("", int(os.environ.get("PORT", "3000"))), CanvasHandler).serve_forever()
Respond with Canvas JSON
Answer with status 2xx and a JSON object that has at least one of canvas,
toast or redirect.
{
"canvas": {
"content": {
"components": [
{ "type": "text", "style": "header", "text": "Account: Lopez Roofing" },
{ "type": "badge", "text": "Renews in 12 days", "color": "yellow" },
{ "type": "button", "label": "Open in CRM", "style": "link",
"action": { "type": "url", "url": "https://crm.example.com/contacts/9d2f7b3a-4e1c-4a8b-b6d5-3c7e0f2a1b84" } }
]
}
},
"toast": { "type": "info", "message": "Synced just now" }
}
canvas.content.componentsis the panel, drawn top to bottom.toastshows a message box.typeissuccess,errororinfo.redirectis{ "url": "...", "target": "_blank" }. It opens a new tab, or navigates the dashboard itself whentargetis_self.
Components
type |
Fields |
|---|---|
text |
text; style: header, body, muted or caption |
badge |
text; color: green, red, yellow, blue or gray |
status_row |
items: [{ label, value, color }], one row of labelled values; color as for badge, default gray |
list |
items: [{ title, subtitle }], the title on the left and the subtitle on the right |
divider |
none |
spacer |
size: small or large |
image |
url, alt |
link |
text, url, target (default _blank), icon |
button |
label, action, style: primary, secondary, danger or link; icon |
button_group |
buttons: an array of button objects without type |
input |
id, label, placeholder |
textarea |
id, label, placeholder, rows (default 3) |
dropdown |
id, label, placeholder, options: [{ value, label }] |
checkbox |
id, label. Sends true or false |
section |
title, icon, collapsed, components. A section folds open and closed |
icon is a Font Awesome class name such as fa-link. Inside a section, only
text, badge, divider, button, button_group, input, textarea,
dropdown and checkbox are drawn. Unknown types are skipped.
Button actions
{ "type": "url", "url": "https://crm.example.com/orders", "target": "_blank" }opens the URL. It doesn't call your server.{ "type": "submit", "payload": { ... } }sends acanvas.actionrequest. Put what your server needs to route the click inpayload, for example{ "action": "save_note" }.
Actions: the round trip
A submit click sends this body, signed the same way:
{
"event": "canvas.action",
"widget_id": "3f6c2a1e-8d4b-4c7a-9e2f-5b1d7c3a9e40",
"slot": "contact_detail",
"action": { "type": "submit", "payload": { "action": "save_note" } },
"input_values": { "note": "Wants a quote for the garage roof" },
"context": { "contact_id": "9d2f7b3a-4e1c-4a8b-b6d5-3c7e0f2a1b84" },
"user": { "user_id": "e4a1c8f2-6b3d-4f9e-a7c5-0d2b8e1f4a69", "email": "alex@example.com", "name": "Alex Kim" },
"team_id": "b7e2d9c4-1a6f-4e3b-8c5d-2f9a0e7b6c13",
"timestamp": "2026-09-30T15:04:09.000Z"
}
context is the same object the render request carried (shortened here).
input_values holds the value of every input, textarea, dropdown and checkbox
the teammate touched, keyed by component id.
Answer within 5 seconds. Your response can carry canvas, toast,
redirect, any mix of them, or none. A new canvas replaces the panel; without
one, the panel stays as it was. Either way, the input values are cleared. If
the request fails, the teammate sees an error message.
Errors and timeouts
| What happens | Limit or rule | What the teammate sees |
|---|---|---|
| Render takes too long | 20 seconds | Webhook request timed out, with a retry link |
| Action takes too long | 5 seconds | An error message |
| Your server redirects | Any 3xx. Redirects aren't followed |
Webhook returned redirect status 302; redirects are not followed |
| Your server answers with an error | Any other status outside 200-299 | Webhook returned error status: 500 (render) or Action webhook returned error: 500 (action) |
The render body has no canvas, toast or redirect |
Invalid Canvas JSON response - must contain canvas, toast, or redirect |
|
| The host can't be reached | DNS or connection failure | Failed to connect to webhook endpoint |
| The URL isn't allowed | Not https://, or resolves to a private address |
Invalid webhook URL: ... |
| The widget is switched off | Widget is disabled |
Requests aren't retried. A teammate can click retry on a failed panel. Every contact view sends a new render request, so cache slow lookups on your server.
Security checklist
- Verify
X-Signatureon every request, over the raw body, with a constant-time compare, and reject timestamps more than 5 minutes old. - Keep the signing secret on your server. Never put it in browser code.
- Use
team_idanduserfor access decisions. Treatcontext,actionandinput_valuesas untrusted input: a teammate's browser builds them. - Check that the
contact_idbelongs to theteam_idyou expect before you return or change data. - Use
https://URLs in links, buttons and redirects. - Your URL must be HTTPS and resolve to a public address. It's checked when you save the widget and again on every request.
- Rotate with Regenerate Secret if the secret leaks, then update your server at once.
Test with curl
Sign a sample request the same way Drop Cowboy does, then send it to your
server. A valid request returns your Canvas JSON. Change one character of the
body and it should return 401.
SECRET="$DC_CANVAS_SECRET"
BODY='{"event":"canvas.render","widget_id":"3f6c2a1e-8d4b-4c7a-9e2f-5b1d7c3a9e40","slot":"contact_detail","context":{"contact_id":"9d2f7b3a-4e1c-4a8b-b6d5-3c7e0f2a1b84","fields":{"company":"Lopez Roofing"}},"user":{"user_id":"e4a1c8f2-6b3d-4f9e-a7c5-0d2b8e1f4a69","email":null,"name":null},"team_id":"b7e2d9c4-1a6f-4e3b-8c5d-2f9a0e7b6c13","timestamp":"2026-09-30T15:04:05.000Z"}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST https://crm.example.com/canvas \
-H "content-type: application/json" \
-H "x-timestamp: $TS" \
-H "x-signature: sha256=$SIG" \
-H "x-signature-version: v1" \
-d "$BODY"
For people who set widgets up without writing code, see Canvas.