Webhooks
A webhook tells your server the moment something happens in Pavr, so you don't have to keep asking. Add your address under Settings → Connections → Webhooks. It has to be an https:// address. Pavr shows you the signing secret once, when you save it, so copy it then. An account has one endpoint. You can pause it, make a new secret or remove it from the same page.
Every event is a POST with a JSON body. Pavr waits 5 seconds to connect and 5 seconds for an answer, and it doesn't follow redirects.
Events
Four events go out. Nothing else triggers a webhook.
| Event | Fires when | data |
|---|---|---|
job.opened |
A request becomes a job | The job |
job.won |
A job is won, which happens when the customer signs the proposal | The job, plus proposal_id
|
payment.received |
A payment lands on an invoice, whether a customer paid it with Pavr Payments, it came back from QuickBooks Online or Xero, or your office recorded it by hand | The invoice, plus a payment object |
esign.signed |
A customer signs a proposal | The signature, described below |
payment.received covers payments on invoices only, and the payments on your own Pavr subscription never send it. Each payment sends it once. A payment your office records in Pavr and then sends to QuickBooks Online or Xero doesn't send it again when it comes back. If your bookkeeper also enters it in QuickBooks Online or Xero, for the same amount on the same invoice and dated within 7 days of it, Pavr counts that as the same payment and doesn't send it again either. A payment for the same amount dated more than 7 days apart is a separate payment and sends its own. A refund or a voided payment doesn't send it at all. esign.signed covers proposals. A signed change order doesn't send it.
The job and the invoice have the same fields as the API's jobs and invoices, so one parser covers both.
The envelope
{
"data": { "id": "…", "job_number": "…", "won_at": "…", "proposal_id": "…" },
"meta": { "event": "job.won", "event_id": "evt_…", "api_version": "v1" },
"errors": null
}
meta.event names the event and meta.event_id identifies it. The id stays the same on every retry of the same event. For payment.received it belongs to the payment itself, so one payment never arrives under two ids.
Payment payload
The invoice fields come first, then:
"payment": {
"payment_id": "…",
"amount_cents": 486000,
"qbo_transaction_id": null,
"qbo_payment_method": null
}
amount_cents is what this payment was, not what the invoice still owes. For that, read balance_due_cents on the invoice. The two qbo_ fields are filled when the payment came back from QuickBooks Online, and they're null for any other payment, including one a customer made on the invoice with Pavr Payments.
Signature payload
| Field | What it is |
|---|---|
signable_type, signable_id
|
Always ProposalVersion, and the id of the version the customer signed |
proposal_id, proposal_version_number
|
The proposal, and which version of it |
signed_at |
When they signed |
signed_by_contact_id, signed_by_contact_name, signed_by_ip
|
Who signed and from where. These are null on an older proposal with no signature on file |
job_id, account_id
|
The job it was sold on, and your account |
Headers
Each delivery carries these headers.
| Header | What it holds |
|---|---|
X-Pavr-Event |
The event name, like job.won
|
X-Pavr-Event-Id |
The same id as meta.event_id
|
X-Pavr-Timestamp |
When Pavr sent this attempt, in Unix seconds |
X-Pavr-Signature |
t=<timestamp>,v1=<signature> |
User-Agent |
Pavr-Webhooks/1.0 |
Verifying the signature
The signature is an HMAC-SHA256, in hex, of the timestamp, a period, and the raw body, made with your endpoint's signing secret. Sign the text exactly as it arrived. Parsing the JSON and writing it back out changes the bytes.
timestamp = request.headers['X-Pavr-Timestamp'].to_s
header = request.headers['X-Pavr-Signature'].to_s
sent = header[/v1=([0-9a-f]+)/, 1].to_s
expected = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, "#{timestamp}.#{request.raw_post}")
verified = ActiveSupport::SecurityUtils.secure_compare(expected, sent)
fresh = (Time.now.to_i - timestamp.to_i).abs <= 300
Accept the delivery only when both are true. Compare in constant time, never with ==, and verify before you parse. The five-minute check keeps someone from replaying a request they recorded. Every retry is signed fresh with its own timestamp, so a late retry passes.
If you make a new signing secret, Pavr signs with it right away, and your server rejects deliveries until you give it the new one.
Retries
A delivery fails when your server answers with anything but a 2xx, or when Pavr can't connect or gets no answer in time.
- 4 attempts at most, counting the first.
- 5 minutes apart, every time. The wait doesn't grow.
- After the fourth failure the delivery shows as "Stopped retrying" and Pavr doesn't send it again.
When three deliveries in a row run out of attempts, the endpoint shows as "Failing" in Settings and Pavr stops sending new events to it. Fix the address, then turn it back on from the same page. Turning it back on starts the count over, so it takes three more deliveries in a row running out of attempts before it shows as "Failing" again. One that gets through starts the count over too. Settings lists the recent deliveries with the status your server returned, which is usually the fastest way to see what went wrong.
Answer with a 2xx as soon as you've stored the event, and do the work after. A slow answer counts as a failure.
Duplicates and order
A retry sends the same event again, and a retry can arrive after a newer event. Keep the event_id of everything you've handled, skip repeats, and read the job's or invoice's current state from the API when order matters.