Integration patterns
Three things the API is good at: getting new work in from your own site, getting job and invoice data out to another tool, and hearing about the four events that matter the moment they happen. Each recipe uses only what the API and webhooks have today.
Capture requests from your own form
Post a request from a landing page, a phone-answering service or a form you already run. It needs an API key with the requests:write permission, which is a different credential from the API token the read endpoints use. Make one under Settings → Connections → API keys.
curl -X POST https://pavr.io/api/v1/requests \
-H "Authorization: Bearer $PAVR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f7a9e2c-1b4d-4e6a-9c3f-8a2d5e7f1b90" \
-d '{"request": {"name": "Dana Ruiz", "email": "dana@example.com", "phone": "555-0143", "address": "18000 Parking Lot Way"}}'
The request lands in your Requests list like any other, and someone on your team makes it a job or declines it. A 201 means it was created. A 422 usually means there was no email or phone to answer, so ask for one on the form. Its errors list says which.
Make the Idempotency-Key one value per form submission, such as the submission's own id. If your server times out and sends it again, Pavr answers 200 with the request it already has instead of making a second one.
Keep another system in step with your jobs
The API can't filter by what changed. Jobs come back newest first by when they were made, not by when they were last touched, so there's no shortcut: to find changes, read every page and compare updated_at. Every 25 jobs is one call against the limit of 60 a minute, so run it on a schedule, not on every page view.
import time
import requests
BASE = "https://pavr.io/api/v1"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
def fetch_all_jobs():
jobs, page = [], 1
while page:
r = requests.get(f"{BASE}/jobs", headers=HEADERS, params={"page": page})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 30)))
continue
r.raise_for_status()
body = r.json()
jobs.extend(body["data"])
page = body["meta"]["next_page"]
return jobs
next_page is null on the last page, which ends the loop. On a 429, wait the seconds in Retry-After and ask for the same page again.
If all you need to know is that a job opened or was won, a payment landed or a proposal was signed, skip the polling and use a webhook.
Handle webhooks safely
Check the signature, hand the event to a worker, and answer right away. The full check, including the timestamp, is in Verifying the signature.
# POST /webhooks/pavr
def create
raw = request.raw_post
timestamp = request.headers['X-Pavr-Timestamp'].to_s
sent = request.headers['X-Pavr-Signature'].to_s[/v1=([0-9a-f]+)/, 1].to_s
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['PAVR_SIGNING_SECRET'], "#{timestamp}.#{raw}")
fresh = (Time.now.to_i - timestamp.to_i).abs <= 300
return head :unauthorized unless fresh && ActiveSupport::SecurityUtils.secure_compare(expected, sent)
event = JSON.parse(raw)
ProcessPavrEventJob.perform_later(event) unless EventLog.seen?(event.dig('meta', 'event_id'))
head :ok
end
If your server is down, Pavr tries four times, five minutes apart. A retry carries the same meta.event_id, so keep the ones you've handled and skip repeats.
Accounting
Pavr connects to QuickBooks Online and Xero directly. Invoices go over, payments come back, and sending an invoice twice still leaves one invoice there. With Pavr Payments on, each payout goes over too, fees and all. Connect either one under Settings → Connections instead of building it yourself. There's no connector for other accounting packages today, so for those, the invoices endpoint is the way to move the numbers. It shows each invoice's status, balance_due_cents and paid_at, and payment.received fires once for every payment that lands on an invoice, including one your office records by hand.
Rate limits
- 60 requests a minute per account, in a fixed window that resets at the top of the minute.
- On a
429, wait the seconds inRetry-After. Don't retry right away. - Posting requests has its own limits: 60 a minute per API key and 100 a minute per IP address. Those
429s are plain text with noRetry-After, so wait a minute. - Cache what you read, and poll on a schedule.