BidTorch's integration surface is outbound webhooks. Every business event POSTs JSON to the destinations a company configures; Make, Zapier, n8n or your own code routes on the event field. There is no app to build and publish first.
Two kinds of destination. A primary webhook URL receives every event, unsigned. Any number of webhook endpoints can be added on top, each with an optional per-event filter and an HMAC signing secret. Both are configured under Settings → Integrations. Zapier subscribes through the REST-hook endpoints below automatically.
Settings → Integrations → API key issues a per-company bearer key (btk_…). Only a hash is stored; the plaintext is shown once. Send it as Authorization: Bearer btk_… (or X-API-Key) to the REST endpoints. Regenerating the key revokes the old one.
POST, Content-Type: application/json, User-Agent: BidTorch/1.0. Every payload carries event, event_id (a uuid, stable across retries — the de-duplication key), sent_at, and an org block with the company's id, name, phone and email.
Durable, at-least-once delivery. Every event is persisted and attempted immediately. A delivery that fails (network, timeout, or a non-2xx response) is retried with exponential backoff — about a minute at first, capped at six hours, up to eight attempts — before being parked. Because retries mean an event can arrive more than once, consumers must de-duplicate on event_id: treat the webhook as a trigger, not a ledger. Ten-second timeout per endpoint; endpoints fire concurrently, so one slow endpoint never blocks another. Each endpoint shows its last delivery time and HTTP status in Settings.
Signing. Deliveries to an endpoint with a secret carry:
X-BidTorch-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
Verify by recomputing the HMAC over "<t>.<raw body>" with the secret shown when the endpoint was added, and optionally rejecting a stale t. The primary webhook URL and Zapier subscriptions are unsigned; Zapier's per-Zap target URLs are unguessable, which is the standard REST-hook trust model.
Every event, when it fires, and the root key of its payload.
| Event | Fires when | Payload root |
|---|---|---|
| quote.sent | Quote goes out to the customer (emailed in-app or marked sent) | quote |
| quote.accepted | Customer accepts an option on the public quote link (signature required) | quote + accepted_option |
| quote.signed | Same moment as quote.accepted — signature is required to accept, so both fire together. Subscribe to either. | quote + accepted_option |
| quote.expired | Nightly sweep: a sent quote passed its expires_at unaccepted (deadline = send time + the org's valid-days setting) | quote |
| job.scheduled | Quote converted to job / job scheduled | job |
| job.assigned | Job assigned to a crew member (notify the tech) | job + assignee |
| job.rescheduled | A scheduled job's date/time moves | job |
| job.started | Status → in progress | job |
| job.completed | Status → completed (the review-request trigger) | job |
| job.cancelled | Status → cancelled | job |
| invoice.sent | Invoice status → sent (Stripe link minted if configured) | invoice + items |
| invoice.paid | Invoice marked paid | invoice + items |
| lead.created | Public lead form submission | lead |
| lead.status_changed | Lead moved on the kanban | lead |
| inventory.low_stock | Stock crosses below an item's reorder point (fires once per low-stock episode, re-armed on restock) | item |
| task.assigned | Task assigned to a teammate | task |
| task.completed | Task marked done | task |
| sms.outbound | A rep sends a text from the Messages inbox — carry this to Twilio (BidTorch stores the message but never sends it) | message_id, to, body, customer_id, quote_id, job_id |
| sms.received | An inbound reply was posted to /api/sms/inbound (use for auto-reply / notify-the-rep) | message_id, from, body, customer_id |
| change_order.sent | Change order sent to the customer | change_order + items |
| change_order.approved | Customer approves the change order (signature) | change_order + items |
| change_order.declined | Customer declines the change order | change_order + items |
Every job.* payload also carries an assignee object (id, name, email, phone, role — or null when unassigned), which is what a scenario reads to text or email the assigned tech on job.assigned.
quote.accepted and quote.signed fire together — signing is acceptance — so subscribe to one. Their payload includes company-side economics (cost basis, gross profit, margin) that never appear on the public quote. pdf_url is the rendered PDF of the signed quote, a signed download link valid about seven days, ample for a scenario to run and retry. quote.sent and quote.expired carry a slim quote object with no economics.
quote.accepted / quote.signed
{
"event": "quote.accepted",
"sent_at": "2026-06-11T18:00:00Z",
"org": { "id": "…", "name": "…", "phone": "…", "email": "…", "address": "…", "website": "…" },
"quote": {
"id": "…", "quote_number": "AP-2026-014", "status": "accepted",
"customer_name": "…", "customer_company": null, "customer_email": "…", "customer_phone": "…",
"customer_address": "…", "job_address": "…",
"subtotal": 0, "tax_amount": 0, "total": 0,
"cost_basis": 0, "gross_profit": 0, "margin_pct": 0,
"accepted_option_id": "…", "accepted_at": "…", "signed_at": null,
"has_signature": false, "share_token": "…", "public_path": "/q/…",
"public_url": "https://…/q/…", "pdf_url": "https://…/q/…?pdf=1", "notes": null
},
"accepted_option": {
"id": "…", "tier": "better", "name": "…",
"subtotal": 0, "svc_call_amount": 0, "total": 0,
"cost_basis": 0, "margin_pct": 0, "notes": null,
"items": [
{ "item_type": "service", "description": "…", "qty": 1, "unit": "ea",
"unit_cost": 0, "markup_pct": 0, "line_total": 0,
"store": null, "po_number": null, "note": null }
]
}
}
job.*
{
"event": "job.completed",
"sent_at": "…",
"org": { "id": "…", "name": "…", "phone": "…", "email": "…" },
"job": {
"id": "…", "job_number": "JOB-2026-007", "title": "…", "status": "completed",
"scheduled_at": "…", "started_at": "…", "completed_at": "…",
"customer_name": "…", "customer_email": "…", "customer_phone": "…",
"job_address": "…", "total": 0, "notes": null, "quote_id": "…", "created_at": "…"
}
}
invoice.sent / invoice.paid
{
"event": "invoice.paid",
"sent_at": "…",
"org": { "id": "…", "name": "…", "phone": "…", "email": "…" },
"invoice": {
"id": "…", "invoice_number": "…", "status": "paid",
"customer_name": "…", "customer_email": "…", "customer_phone": "…",
"customer_address": "…", "job_address": "…",
"subtotal": 0, "tax_amount": 0, "amount_due": 0, "amount_paid": 0,
"stripe_payment_link_url": null, "due_date": "…",
"sent_at": "…", "paid_at": "…", "share_token": "…", "public_path": "/i/…", "org_id": "…"
},
"items": [
{ "description": "…", "qty": 1, "unit": "ea", "unit_price": 0, "line_total": 0, "sort_order": 0 }
]
}
lead.*
{
"event": "lead.created",
"sent_at": "…",
"org": { "id": "…", "name": "…", "phone": "…", "email": "…" },
"lead": {
"id": "…", "status": "new", "name": "…", "email": "…", "phone": "…",
"address": "…", "service_type": "…", "message": "…", "source": "…", "created_at": "…"
}
}
The small authenticated surface: the REST-hook subscribe/unsubscribe pair Zapier uses, a sample payload per event for a trigger test, and the two inbound sinks.
| Endpoint | Purpose |
|---|---|
| GET /api/zapier/me | Auth test — returns { id, name }, labels the Zapier connection |
| POST /api/zapier/hooks | Subscribe: { "target_url": "https://…", "event": "quote.accepted" } → { "id" } (201) |
| DELETE /api/zapier/hooks/{id} | Unsubscribe (idempotent — already-gone is success) |
| GET /api/zapier/samples/{event} | One static sample payload (exact live shape) for the Zap editor's trigger test |
| POST /api/sms/inbound | Inbound SMS sink — a Make scenario on Twilio's incoming-message webhook POSTs { "from", "body", "provider_sid?" } here (same Bearer btk_… auth) → stored as an inbound message, returns { "id" } (201) |
| POST /api/leads/inbound | Inbound lead sink — an outside automation (AI phone concierge, chatbot, zap) POSTs { "name", "phone?", "email?", "address?", "service_type?", "message?", "source?" } (same Bearer btk_… auth) → lands in the Leads pipeline as new and fires lead.created, returns { "id" } (201) |
An outside form, chatbot, phone system or Zap can create a lead. It lands in the pipeline as new and fires lead.created:
curl -X POST https://bidtorch.com/api/leads/inbound \
-H "Authorization: Bearer btk_..." \
-H "Content-Type: application/json" \
-d '{"name":"Pat Doe","phone":"317-555-0100","service_type":"Water heater","message":"No hot water since this morning","source":"website"}'Two-way SMS has two transports, chosen per company. The default is webhook-based: a rep sends from the Messages inbox, BidTorch stores it and fires sms.outbound for your scenario to carry to Twilio; a reply comes back through POST /api/sms/inbound. The native transport talks to the company's own Twilio account directly with real delivery status, and inbound messages arrive on a signed, per-company Twilio webhook URL shown in Settings. Carrier registration for business texting applies either way.
Settings → Calendar feed) that Google, Apple and Outlook subscribe to. Regenerating the token revokes every previously shared URL.Questions
Do I have to build and publish an app to get BidTorch events?
No. Paste a destination URL under Settings → Integrations and BidTorch POSTs the full event as JSON — signed, retried, with a stable event_id for de-duplication. Make, Zapier, n8n or your own code can listen.
Is the webhook delivered exactly once?
At least once. A failed delivery (network, timeout, non-2xx) is retried with exponential backoff, up to eight attempts, so an event can arrive more than once. De-duplicate on event_id; treat the webhook as a trigger, and the API or CSV export as the source of truth.
How do I get data INTO BidTorch?
POST a lead to /api/leads/inbound with your company API key and it lands in the pipeline as new and fires lead.created. Inbound SMS replies post to /api/sms/inbound the same way. Everything else today is outbound.
Does BidTorch have a Zapier app?
Yes, a triggers-only app that authenticates with the same company API key and subscribes through the REST-hook endpoints below. It is invite-only while the public listing is pending — ask support for the link.
Something missing? Ask support — a person answers.