Create a key in Settings → Developers (studio owners and admins only). Send it in the Authorization header on every request. Keys start with sr_live_ and are shown once, so store them in a secret manager, never in client-side code.
Each key belongs to one studio and carries the scopes you picked. Calls outside those scopes return 403 insufficient_scope.
Errors use conventional HTTP status codes and a consistent body with a machine-readable code, a human-readable message, and the request id. Every response, successful or not, also carries the request id in the X-Request-Id header; send your own X-Request-Id to correlate with your logs.
{
"error": {
"code": "invalid_request",
"message": "Provide an email or a phone number.",
"details": [
{
"path": "email",
"message": "Provide an email or a phone number."
}
]
},
"request_id": "f84555f8-27f0-45b9-9abb-2b8866bbf3b0"
}
Status
Code
Meaning
400
invalid_request
A parameter or body field failed validation. details lists each problem.
400
invalid_json
The body isn't valid JSON.
400
invalid_cursor
The pagination cursor is malformed.
401
unauthorized
The key is missing, malformed, expired or revoked.
403
insufficient_scope
The key doesn't have the scope this endpoint needs.
404
not_found
Nothing with that id exists in your studio.
415
unsupported_media_type
Send bodies with Content-Type: application/json.
422
invalid_reference
A referenced page or campaign belongs to another studio.
429
rate_limited
Slow down. Retry after the number of seconds in Retry-After.
500
internal_error
Something broke on our side. Retrying is safe for GET requests.
List endpoints return newest items first in pages of up to 100 (limit, default 25). When has_more is true, pass next_cursor back as cursor to fetch the next page. Cursors are stable even while new items arrive.
let cursor;
do {
const url = new URL("https://shutterreach.com/api/v1/leads");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SHUTTERREACH_API_KEY}` },
});
const page = await response.json();
for (const lead of page.data) console.log(lead.name, lead.status);
cursor = page.next_cursor;
} while (cursor);
Each key can make 600 requests per minute. Over the limit, requests return 429 rate_limited with a Retry-After header. For bulk syncs, page with limit=100 and prefer webhooks over polling.
Move an inquiry through your pipeline. Setting status: "booked" with booked_value_cents reports the booking and its value to Google Ads when the inquiry came from an ad click. Fires lead.updated when status or value changes.
Path parameters
iduuidrequired
Path parameter. The inquiry id.
Request body
statusenum
newcontactedconsult_bookedbookedlostspam
booked_value_centsinteger | null
Contract value; reported to Google Ads as conversion value when booked.
Add an endpoint in Settings → Developers and choose the events it should receive. We send each event as a JSON POST with these headers:
X-ShutterReach-Event (the event type), X-ShutterReach-Delivery (unique per delivery), and X-ShutterReach-Signature (see below).
Respond with any 2xx within 10 seconds. Timeouts, network errors and 5xx/408/429 responses retry with exponential backoff, up to 10 attempts over about a day. Other 4xx responses stop retries. You can resend any failed delivery from the delivery log. Events can arrive out of order or more than once; use the top-level id to de-duplicate.
The signature header looks like t=1792199317,v1=5f8a…. Compute an HMAC-SHA256 of <t>.<raw body> using your endpoint's signing secret, compare it to v1 in constant time, and reject timestamps more than five minutes old to block replays. Rotating the secret in settings takes effect immediately.
Node.js
import crypto from "node:crypto";
// rawBody: the exact bytes we sent, before JSON parsing.
// secret: your endpoint's signing secret, including the "whsec_" prefix.
export function verifyShutterReachSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const received = Buffer.from(parts.v1 ?? "", "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
Python
import hashlib, hmac, time
def verify_shutterreach_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp = int(parts.get("t", "0"))
if abs(time.time() - timestamp) > 300:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))