API & Webhooks — connect Wraptor to Zapier and your own tools
Who it's for
Owners (and the tech-comfortable manager) who want Wraptor talking to the rest of their stack: Zapier automations, a custom dashboard, a spreadsheet that pulls live numbers, or lead sources that push straight into the Lead Inbox. Included with Wraptor Shop and Wraptor Fleet.
Where it fits
Outside the job workflow itself — this is the integration layer around it. Events fire from the moments that matter to the shop: a lead arrives, a job moves a stage, a customer approves a quote, a payment lands.
Overview
Two halves:
- The REST API (
https://wraptor.app/api/v1) lets outside software read your customers, jobs, quotes, invoices, leads and purchase orders — and create customers and leads. Every request authenticates with an API key you mint in Settings. - Outbound webhooks push events to a URL you control the moment they happen, signed so your receiver can prove they came from Wraptor. This is what a Zapier trigger rides on.
The machine-readable spec lives at /api/v1/openapi.json.
Screens & navigation
Settings → API & Webhooks (under Integrations & Billing, or straight
to /settings?section=developer). One panel, two cards:
- API Keys — every active key on one row: its name, its display prefix
(
wrk_live_a1b…), either "used 3d ago" or "never used", and a Revoke… link at the end. - Webhook Endpoints — every registered URL with the events under it ("All events" when you picked none), a green dot when it is on and a grey one when it is off, and Test · Disable/Enable · Delete…. Underneath, Recent Deliveries lists the last 20 real deliveries: the event name, the attempt number once one has been retried, the error and HTTP code on a failure, and how long ago it ran.
Capabilities
API keys
- Create key — name it after what will hold it ("Zapier", "reporting sheet"). The full key appears once, in a copy box headed "Your new API key — copy it now, it won't be shown again." Only a hash and the first twelve characters are stored; Wraptor cannot show it again.
- Revoke… — a two-step inline confirm. It takes effect immediately.
- Up to 10 active keys per shop. Each key is shop-wide: full access to everything below, with no per-resource scopes.
- The last-used time is refreshed at most every five minutes, so a busy key can read a few minutes behind.
REST API
- List and get by id:
customers,jobs,quotes,invoices,purchase-orders. Leads are list-only. - Write:
POST /customersandPOST /leads. Both answer201with the new record, or200with the record already on file when the body is a duplicate — so a client retry or a CRM re-sync never makes a second copy. A customer is a duplicate on the same email or phone, or on the same name when neither is sent. Email, name and lead-contact matches ignore case and surrounding spaces but are otherwise exact (a_b@x.comnever matchesaxb@x.com). A lead is a duplicate when its contact, vehicle, service and message match one saved in the last 24 hours (at any age when it is sent with"imported": true). - In a write body, an empty string or
nullcounts as not sent. A rejected body answers400with a message that names the field (contact: …). - Idempotency-Key. Every
POSTandPATCHtakes an optionalIdempotency-Keyheader: any unique string up to 255 characters (a UUID works). Send the same key with the same path and body again within 24 hours and you get the first answer back, withIdempotent-Replayed: true, and nothing runs a second time — no second record, bell or webhook. Keys belong to your shop, so two shops never collide.- Same key, different body or path:
422, codeidempotency_key_reused. Use a new key for a new request. - A retry that arrives while the first request is still running:
409, codeidempotency_in_progress, withRetry-After: 1. Retry and you get the stored answer. - Only
2xxanswers are stored. After a400or other error you can fix the body and retry with the same key. - Key order and spacing in the JSON body do not matter. After 24 hours the same key runs as a new request; stored answers are deleted after 7 days.
- Without the header nothing changes: the duplicate checks above still apply. Use a key whenever your client or agent retries on timeouts.
- Same key, different body or path:
- Lists paginate by cursor:
?limit=(25 by default, 100 at most) and?cursor=taken from the previous page'snext_cursor, which isnullon the last page.?updated_since=(an ISO 8601 timestamp) narrows a list to what changed since your last poll — the Zapier polling filter. Leads never change once captured, so theirs filters on the created time. - A successful read returns
{ "data": … }, a list addsnext_cursor, and anything that fails returns{ "error": { "code": …, "message": … } }with a matching HTTP status. GET /meverifies a key and names the shop (Zapier's auth test).- 120 requests a minute per key; over the limit returns
429with aRetry-Afterheader. GET /jobsleaves out the seeded sample job. A job'scustomer_idisnullwhen it was imported without a customer.- Quotes and invoices carry the second tax line, when there is one, as
tax_amount_2andtax_label_2. - An API-created lead lands in the Lead Inbox and rings the bell ("New API
lead: …"), which opens that lead's card. Its source is
api, and it files under the Imported filter on Leads → Leads. - Send
"sms_consent": trueonPOST /leadsonly when the person agreed to get texts from you. Replies from the Lead Inbox go to a phone contact by text only when it is on; a phone number alone never counts. It defaults tofalse. Re-sending a saved lead withtrueturns it on; leaving it out never turns it off. Leads read back withsms_consent.
Webhook events
job.created, job.stage_changed, quote.approved, quote.declined,
proof.approved, proof.changes_requested, document.signed, invoice.paid,
invoice.deposit_paid, lead.created, booking.created, customer.created,
inspection.completed, purchase_order.ordered, purchase_order.received,
call.completed, call.missed, payroll.finalized.
Every delivery uses the same envelope — event, entity_type, entity_id,
created_at, and a data object holding that event's own fields:
{
"event": "job.stage_changed",
"entity_type": "job",
"entity_id": "…",
"created_at": "2026-09-06T15:04:05.000Z",
"data": { "job_number": "…", "title": "…", "from": "…", "to": "…" }
}
job.createdfires when a job is created in the app — the New Job form, Rex on your say-so, Convert to job or Create quote on a Leads card (email leads included), a customer sending the public/request-quoteform, or an online booking request. A job minted by a quote approval does not repeat it — that arrives asquote.approved, whose payload carries the newjob_id— and neither do jobs added by a bulk import.quote.approvedandquote.declinedfire when the customer answers on their own quote page. Recording the yes yourself with the Mark approved sheet does not fire them.invoice.paidandinvoice.deposit_paidfire for card and bank payments through Stripe and for payments you record by hand (cash, Zelle, check). Either way the payload carriesinvoice_number,amount,method,invoice_total,fully_paidandjob_id. Once your shop offers monthly payments,methodcan also beAFFIRM,KLARNAorFINANCING(Affirm or Klarna, when Stripe didn't say which).inspection.completedfires when a check-in/check-out vehicle inspection is submitted (either lane — installer tablet or front desk). The payload carries the report id, job id, kind, each checklist item's grade, photo and video counts, and the signer name/time — no file URLs.purchase_order.orderedfires when a PO is marked ordered or emailed to the vendor;purchase_order.receivedfires when it becomes fully received (a short-close counts, flaggedshort_closed: true). Payloads carry the PO number, status, vendor id/name, the ordered/expected/received timestamps, line count and cent totals — line detail comes fromGET /purchase-orders/{id}.call.completed/call.missedfire when a call to the shop's tracking number ends (answered vs. not), so they only ever arrive once call tracking is switched on for your shop. Payloads carry the caller and tracking numbers, the matched customer/lead ids (null if unrecognized), and — for completed calls — the duration in seconds. See calls.md.payroll.finalizedfires when a pay period is finalized on Money → Payroll. The payload carries the period start/end, line count, total hours, and base/commission/total cent amounts — deliberately no per-person lines (wages stay inside the app; export the CSV or PDF for detail).
Endpoints
- Add endpoint — paste the URL and tap the events you want. Pick none and
the endpoint takes all of them ("No events selected — the endpoint receives
all events."). The signing secret (
whsec_…) is shown once, right after you add it. Up to 10 endpoints per shop. - Verify — each POST carries
wraptor-id,wraptor-timestamp, andwraptor-signature: v1,<base64 HMAC-SHA256>overid.timestamp.body, keyed with your secret's base64 part (everything afterwhsec_) — the svix scheme, so any svix client library verifies it by mapping the header names. The user agent isWraptor-Webhooks/1.0. - Retries — a failed delivery is due again after 2 minutes, then 8, then every 15, for up to 6 attempts (about 55 minutes end to end). The retry sweep runs every half hour, so a re-send can land later than its due time. An endpoint is switched off only when its last 3 deliveries all ran out of attempts and nothing has gone through for 24 hours. The row then reads "auto-disabled after repeated failures — fix the URL and re-enable", and the shop gets a bell and push, "A webhook was turned off", that opens Settings → API & Webhooks. Re-enabling starts a fresh count.
- Test — fires a
test.pingat one endpoint and answers with a Test delivered (HTTP 200) toast, or Test failed and the reason. A test ping is one attempt only: it is never retried and never counts toward switching an endpoint off. Test pings stay out of Recent Deliveries, and the button is greyed out while an endpoint is disabled. - Disable / Enable / Delete… — disabling stops deliveries and keeps the endpoint and its secret listed; re-enabling clears the auto-disable stamp; deleting removes it for good.
- Manage endpoints from the API too:
GETandPOST /api/v1/webhooks, thenGET,PATCHandDELETE /api/v1/webhooks/{id}. Create returns the secret once; delete unsubscribes. That is the REST-hook shape Zapier expects.
Step-by-step tasks
-
Connect a spreadsheet or dashboard
- Settings → API & Webhooks → type a name → Create key.
- Copy the key from the one-time box, then click I've saved it.
- Call
GET https://wraptor.app/api/v1/jobswith headerAuthorization: Bearer wrk_live_…. - Follow
next_cursoruntil it is null; poll withupdated_sinceafter.
-
Get notified the moment a lead arrives
- Add endpoint with your receiver URL, subscribed to
lead.created. - Store the one-time secret; verify
wraptor-signatureon each POST. - Click Test to confirm your receiver answers 2xx.
- Add endpoint with your receiver URL, subscribed to
-
Push leads in from an outside form
POST /api/v1/leadswith{ "name": "...", "contact": "...", "vehicle": "...", "service": "...", "timeline": "...", "message": "..." }— onlynameandcontactare required. Add"sms_consent": truewhen your form asked for permission to text, and anIdempotency-Keyheader (one per submission) so a retry never doubles the lead.- The lead appears under Leads → Leads and rings the bell. Several API leads inside 10 minutes fold into one "N new API leads" bell, which opens the Imported filter rather than one lead's card. Imported leads (next step) never ring and don't add to that count.
- Loading contacts you already have (a CRM sync or backfill)? Add
"imported": trueto the body. The lead is saved without a bell or push, and re-sending it later returns the saved one instead of a copy.
Settings & permissions
- API keys and webhooks come with Wraptor Shop and Wraptor Fleet (and the grandfathered Wraptor Pro (original)). On Wraptor Starter the panel reads "API access is included with Wraptor Shop." with an Upgrade to Wraptor Shop → link instead of the two cards.
- The 7-day card-backed trial does not open it — API access needs a live paid
subscription. A failed payment keeps working through the grace period; once
the subscription actually ends, existing keys stop authenticating (
403, codeplan_required) while your endpoints stay registered. - Opening Settings needs the settings permission, and this panel itself runs on the billing permission — reading the key list, creating a key and managing endpoints all require it. Owners and Managers have both.
Tips & common pitfalls
- Copy secrets when shown. Keys and signing secrets appear exactly once. Lose one → revoke/delete and create a fresh one; nothing else re-reveals it.
- Answer webhooks fast with a 2xx. Do your real work after responding — a slow receiver hits the 10s timeout and looks like a failure.
- Don't redirect. Wraptor never follows a 3xx, so a redirect counts as a failed delivery. Register the final URL.
- Use
wraptor-idfor idempotency. Retries reuse the same id; process each id once. - Send an
Idempotency-Keyon writes. Make one key per record you mean to create and reuse it on every retry of that request. A new key on each retry defeats it. - Localhost and private-network URLs are rejected, including IPv6 forms
like
[::1],[fd00::1]or[::ffff:127.0.0.1]. Receivers must be publicly reachable, over http or https. - Deleting an endpoint stops its deliveries immediately; disabling keeps it listed so you can re-enable without re-sharing a secret.
Related modules
- Marketing — the Lead Inbox, where API-created leads land.
- Jobs — the stages behind
job.stage_changed. - Invoicing & Billing — the payments behind
invoice.paid. - Plans — what each tier includes.