Webhooks
Send signed contact and account events to a business's Pairly CRM.
One endpoint takes every event. The event field picks the shape.
POST https://api.pairlyhq.com/v1/crm/webhooks/{business_id}/events
Headers
| Header | Required | Value |
|---|---|---|
Content-Type | yes | application/json |
X-Pairly-Signature | yes | sha256= + hex HMAC-SHA256 of the raw body, keyed with the secret |
X-Pairly-Key-Id | no | The key's id; Pairly then checks only that key |
Signing a request
Sign the exact bytes you send. Serialise the JSON once, sign that string, and send that same string.
BODY='{"event":"person.upsert","source":"acme-site","person":{"email":"jules@example.com"},"tags":["lead"]}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$PAIRLY_KEY_SECRET" | sed 's/^.* //')
curl -X POST "https://api.pairlyhq.com/v1/crm/webhooks/$BUSINESS_ID/events" \
-H "Content-Type: application/json" \
-H "X-Pairly-Signature: sha256=$SIG" \
-d "$BODY"
import crypto from 'node:crypto'
const body = JSON.stringify({ event: 'person.upsert', source: 'acme-site', person: { email: 'jules@example.com' }, tags: ['lead'] })
const signature = 'sha256=' + crypto.createHmac('sha256', process.env.PAIRLY_KEY_SECRET).update(body, 'utf8').digest('hex')
const res = await fetch(`https://api.pairlyhq.com/v1/crm/webhooks/${process.env.BUSINESS_ID}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Pairly-Signature': signature },
body,
})
import hashlib, hmac, json, os, urllib.request
body = json.dumps({"event": "person.upsert", "source": "acme-site",
"person": {"email": "jules@example.com"}, "tags": ["lead"]}).encode()
sig = "sha256=" + hmac.new(os.environ["PAIRLY_KEY_SECRET"].encode(), body, hashlib.sha256).hexdigest()
req = urllib.request.Request(
f"https://api.pairlyhq.com/v1/crm/webhooks/{os.environ['BUSINESS_ID']}/events",
data=body, method="POST",
headers={"Content-Type": "application/json", "X-Pairly-Signature": sig},
)
urllib.request.urlopen(req)
// WordPress: forward a Contact Form 7 submission (Gravity Forms: gform_after_submission)
add_action('wpcf7_mail_sent', function ($form) {
$data = WPCF7_Submission::get_instance()->get_posted_data();
$body = wp_json_encode([
'event' => 'person.upsert',
'source' => 'acme-wordpress',
'person' => ['email' => $data['your-email'], 'first_name' => $data['your-name'] ?? null],
'tags' => ['website_lead'],
]);
wp_remote_post('https://api.pairlyhq.com/v1/crm/webhooks/' . PAIRLY_BUSINESS_ID . '/events', [
'headers' => [
'Content-Type' => 'application/json',
'X-Pairly-Signature' => 'sha256=' . hash_hmac('sha256', $body, PAIRLY_KEY_SECRET),
],
'body' => $body,
'timeout' => 10,
]);
});
Body
| Field | Type | Notes |
|---|---|---|
event | string | One of the six events below |
source | string, 1–100 chars, required | Your integration's name, free text. Helps the business tell traffic apart |
person | object | email (required), first_name, last_name, phone, company_domain (links an existing company with that domain; never creates one, use company.upsert) |
company | object | name (required), domain |
tags | string[] | Merged onto the contact; never removes a tag |
event_name | string | user.event only, required there |
properties | object | user.event only, stored verbatim |
idempotency_key | string, 1–200 chars | user.event only: resend with the same key and the activity is recorded once |
email_marketing_consent | object | granted (required bool), granted_at (ISO datetime), method (≤100 chars), source_url (URL). true records consent with this request as evidence, false revokes it, and omitting it changes nothing |
Events
event | Requires | Does | Takes tags | Takes consent |
|---|---|---|---|---|
person.upsert | person | Creates or fills in a contact matched by email | yes | yes |
company.upsert | company | Creates or matches a company by domain | no | no |
person.deleted | person | Archives the contact; idempotent. A later upsert for the same email finds the archived contact and does not un-archive it | no | no |
user.created | person | As person.upsert, plus an "Account created" note on the contact | yes | yes |
user.email_confirmed | person | Records the confirmation; creates the contact if new | yes | yes |
user.event | person, event_name | Logs any named moment with your properties | yes | no |
Tags are the business's segmentation. Pairly gives tag names no meaning; the business builds newsletter and campaign audiences from them.
Responses
| Status | Body | Meaning | What to do |
|---|---|---|---|
200 | {"status":"ok","person_id":"…"} (company_id for company.upsert; no id for person.deleted) | Applied | — |
400 | {"detail":"Malformed webhook payload"} or {"detail":"<event> requires …"} | Body did not parse or a required object is missing | Fix the payload; retrying unchanged fails again |
401 | {"detail":"Invalid signature"} | Wrong secret, body changed after signing, or key rotated/revoked. Deliberately not distinguished | Check you sign the exact bytes; ask the business for a current key |
413 | {"detail":"Request body too large"} | Body over 1,124,000 bytes | Send less |
Retry only on network errors and 5xx, with backoff. person.upsert, company.upsert and person.deleted are safe to retry, and user.event is safe with an idempotency_key. user.created and user.email_confirmed add a lifecycle note on every call, so a retry may add a duplicate note.