Skip to main content

Developers

Webhook reference — events, payloads, retries

Pavr webhook reference: the four events, what each payload carries, how to verify the X-Pavr-Signature header, and what happens when your server is down.

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.

Back to the API overview