SignalRoute API · v1

API reference

REST + JSON. API keys with scopes. Cursor pagination. Idempotency keys. Stripe-style webhooks with HMAC SHA-256 signing. Request IDs on every response.

Base URLhttps://www.getsignalroute.com/api/v1

#Overview

The SignalRoute API is the same surface our dashboard uses. It models the resource graph: Organization → Business → Routes → Sessions → (Ratings, Public clicks, Private feedback). Every customer-facing review link is a Route; every customer journey through a Route is a Session.

  • REST + JSON over HTTPS. No GraphQL.
  • Snake-case field names. Opaque IDs (biz_…, route_…).
  • Cursor pagination on all list endpoints.
  • Idempotency keys on writes that integrations might retry.
  • Webhooks for asynchronous workflows (Zapier, n8n, Make, custom).
  • Public route endpoints are unauthenticated and rate-limited.

#Authentication

Two flavors of auth. Pick the right one for the caller.

1. API key (external integrations)

Send an Authorization header with a Bearer token. Keys start with sr_live_ or sr_test_.

Authorization: Bearer sr_live_xxxxxxxxxxxxxxxxxxxxxxxx

Keys are scoped to a single organization and carry a list of permission scopes. Mint keys from the dashboard or via POST /v1/api-keys (session-only). Plaintext is shown exactly once at creation — store it in a secrets manager.

2. Session auth (first-party dashboard)

When the API is called from the SignalRoute dashboard, the request carries the session cookie set at login. Session callers see every organization they're a member of and have implicit access to every scope.

Never ship API keys to the browser. The widget and customer-facing review pages use unauthenticated public endpoints, not API keys.

#API key scopes

API keys grant access only to scopes assigned at creation. Read scopes return data; write scopes mutate. Use the narrowest set the integration needs.

  • businesses:read
  • businesses:write
  • platform_links:read
  • platform_links:write
  • routes:read
  • routes:write
  • sessions:read
  • sessions:write
  • feedback:read
  • feedback:write
  • review_requests:read
  • review_requests:write
  • campaigns:read
  • campaigns:write
  • team_members:read
  • team_members:write
  • print_materials:read
  • print_materials:write
  • webhooks:read
  • webhooks:write
  • reports:read
  • agency_clients:read
  • agency_clients:write
  • integrations:read
  • integrations:write
  • analytics:read

#Errors

Every error response uses the same shape. request_id matches the SignalRoute-Request-Id response header — include it when reporting bugs.

{
  "error": {
    "code": "route_not_found",
    "message": "No active route was found for this ID.",
    "request_id": "req_3y9hf...",
    "details": []
  }
}
codestatuswhen
unauthorized401Missing or invalid API key / session.
forbidden403Authenticated but lacks permission or scope.
not_found404Resource does not exist or is not visible.
validation_error422Request body or query failed schema validation.
rate_limited429Too many requests; respect Retry-After.
conflict409State conflict — e.g. duplicate slug, double-rate.
idempotency_conflict409Same Idempotency-Key reused with a different body.
integration_error502Downstream integration provider failed.
webhook_delivery_failed502Webhook receiver returned non-2xx.
internal_error500Unexpected server error — check request_id.

#Pagination

List endpoints accept ?limit= (default 25, max 100) and ?cursor=.

GET /v1/sessions?business_id=biz_123&limit=50

{
  "items": [ /* … */ ],
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAy..."
}

Pass next_cursor back as cursor on the next request. When next_cursor is null you've reached the end.

#Idempotency

Send Idempotency-Key on retryable writes. Recent results (24h) are replayed verbatim. Reusing a key with a different body returns 409 idempotency_conflict.

Supported on:

  • POST /v1/review-requests
  • POST /v1/routes/:routeId/sessions
  • POST /v1/sessions/:sessionId/private-feedback
Idempotency-Key: housecall-job-5821-review-request

#Rate limits

Public, unauthenticated customer-facing endpoints are rate-limited per IP. When you hit a limit you get 429 with a Retry-After header.

  • GET /v1/public/routes/:slug/config — 60/min/IP
  • POST /v1/routes/:routeId/sessions — 30/min/IP
  • POST /v1/sessions/:sessionId/rating — 10/min/IP
  • POST /v1/sessions/:sessionId/public-click — 10/min/IP
  • POST /v1/sessions/:sessionId/private-feedback — 5/min/IP

#Request ID

Every response carries SignalRoute-Request-Id: req_…. The same value is echoed in error bodies as request_id. Log it on your side; share it when reporting issues.

#Organizations

An organization is the top-level tenant. Type is business (a single merchant), agency (manages other businesses), or admin.

POST/v1/organizations
session only

Create a new organization. Session auth only — API keys can't escalate.

{
  "name": "Wade's Plumbing & Septic",
  "type": "business",
  "owner": { "name": "Byron Wade", "email": "bw@wadesinc.io" }
}
GET/v1/organizations/:organizationId
PATCH/v1/organizations/:organizationId
DELETE/v1/organizations/:organizationId

Soft-archives the organization (sets archived_at).

#Businesses

A business is a merchant. Routes and Platform Links live under it. Status is active · paused · archived · suspended · demo.

POST/v1/businesses
scope: businesses:write
{
  "organization_id": "org_123",
  "name": "Wade's Plumbing & Septic",
  "slug": "wades-plumbing",
  "industry": "plumbing",
  "website_url": "https://wadesplumbingandseptic.com",
  "phone": "831-430-6011",
  "email": "bw@wadesinc.io",
  "timezone": "America/New_York",
  "address": {
    "line1": "Jasper", "city": "Jasper",
    "state": "GA", "postal_code": "30143", "country": "US"
  }
}
GET/v1/businesses
scope: businesses:read
organization_idstring
statusenum
limitnumber
cursorstring
GET/v1/businesses/:businessId
scope: businesses:read
PATCH/v1/businesses/:businessId
scope: businesses:write
POST/v1/businesses/:businessId/archive
scope: businesses:write

#Routes

The heart of the product. A Route is the customer-facing review link / QR code. Types: main, campaign, team_member, location, print, widget, integration.

POST/v1/routes
scope: routes:write
{
  "business_id": "biz_123",
  "name": "Main Review Link",
  "slug": "wades-plumbing",
  "type": "main",
  "rating_threshold": 4,
  "private_feedback_enabled": true,
  "public_platforms_enabled": true,
  "platform_link_ids": ["plink_123"],
  "copy": {
    "headline": "How was your experience?",
    "subheadline": "Your feedback helps us improve.",
    "private_feedback_headline": "Tell us what went wrong",
    "private_feedback_body": "We appreciate the chance to make things right.",
    "positive_review_headline": "Thanks! Where would you like to leave a review?"
  }
}
GET/v1/routes
scope: routes:read
business_idstringrequired
GET/v1/routes/:routeId
scope: routes:read
GET/v1/routes/by-slug/:slug
scope: routes:read
PATCH/v1/routes/:routeId
scope: routes:write
POST/v1/routes/:routeId/archive
scope: routes:write

#QR codes

Render a QR code for a route as PNG or SVG. URL points at the public route page so the QR resolves directly without an extra hop.

GET/v1/routes/:routeId/qr-code.png
scope: routes:read
sizenumberdefault: 1024
marginnumberdefault: 2
GET/v1/routes/:routeId/qr-code.svg
scope: routes:read

#Public route config

No auth · rate-limited · safe for browsers

GET/v1/public/routes/:slug/config
no auth
{
  "slug": "wades-plumbing",
  "business_name": "Wade's Plumbing & Septic",
  "logo_url": "https://cdn.getsignalroute.com/logos/wades.png",
  "headline": "How was your experience?",
  "subheadline": "Your feedback helps us improve.",
  "rating_threshold": 4,
  "theme": { "primary_color": "#111827", "background_color": "#ffffff" }
}

#Review sessions

A session represents one customer journey through a Route. Created when the route page loads or the QR is scanned. Statuses: started · rated · public_clicked · private_feedback_submitted · completed · abandoned.

POST/v1/routes/:routeId/sessions
public · rate-limitedidempotency-key
{
  "source": "qr",
  "source_detail": "invoice-card",
  "customer": {
    "name": "Marcus Johnson",
    "email": "marcus@example.com",
    "phone": "555-555-5555"
  },
  "metadata": {
    "invoice_id": "INV-1048",
    "job_id": "JOB-5821",
    "technician": "Byron"
  }
}
GET/v1/sessions/:sessionId
scope: sessions:read
GET/v1/sessions
scope: sessions:read
business_idstringrequired
route_idstring
statusenum

#Ratings

Submitting a rating advances the session. The response branches on whether the rating meets the route threshold.

POST/v1/sessions/:sessionId/rating
public · rate-limited
// Request
{ "rating": 5 }

// Response (positive — route to public review)
{
  "session_id": "sess_123",
  "rating": 5,
  "next_step": "public_review",
  "public_platforms": [
    { "id": "plink_123", "platform": "google", "label": "Google",
      "url": "https://g.page/r/example-review-link" }
  ],
  "created_at": "2026-05-04T12:00:00.000Z"
}

// Response (negative — route to private feedback)
{
  "session_id": "sess_123",
  "rating": 2,
  "next_step": "private_feedback",
  "private_feedback": {
    "headline": "Tell us what went wrong",
    "body": "We appreciate the chance to make things right."
  },
  "created_at": "2026-05-04T12:00:00.000Z"
}

#Public clicks

Record that a customer clicked through to a public review platform. The response echoes redirect_url so the frontend can navigate there.

POST/v1/sessions/:sessionId/public-click
public · rate-limited
// Request
{ "platform_link_id": "plink_123" }

// Response
{
  "id": "pclick_123",
  "session_id": "sess_123",
  "business_id": "biz_123",
  "route_id": "route_123",
  "platform": "google",
  "redirect_url": "https://g.page/r/example-review-link",
  "created_at": "2026-05-04T12:00:00.000Z"
}

#Private feedback

Captured when a rating falls below the route threshold. Statuses: new, seen, in_progress, resolved, ignored, archived.

POST/v1/sessions/:sessionId/private-feedback
public · rate-limitedidempotency-key
{
  "message": "Service was slower than expected and the part wasn't in stock.",
  "contact_permission": true,
  "customer": {
    "name": "Marcus Johnson",
    "email": "marcus@example.com",
    "phone": "555-555-5555"
  }
}
GET/v1/private-feedback
scope: feedback:read
business_idstringrequired
statusenum
GET/v1/private-feedback/:feedbackId
scope: feedback:read
PATCH/v1/private-feedback/:feedbackId
scope: feedback:write
{ "status": "resolved", "internal_note": "Called customer; partial refund issued." }

#Review requests

The integration-friendly endpoint. External tools (Housecall Pro, Jobber, Square, Zapier, n8n) call this after a job completes to send the customer a review link. Channels: sms · email · manual · api · qr.

POST/v1/review-requests
scope: review_requests:writeidempotency-key
{
  "business_id": "biz_123",
  "route_id": "route_123",
  "customer": {
    "name": "Marcus Johnson",
    "email": "marcus@example.com",
    "phone": "555-555-5555"
  },
  "channel": "sms",
  "send_at": "2026-05-04T18:00:00.000Z",
  "message": "Thanks for choosing Wade's. How was it? {{review_link}}",
  "metadata": {
    "source": "housecall_pro",
    "job_id": "JOB-5821",
    "invoice_id": "INV-1048"
  }
}
GET/v1/review-requests
scope: review_requests:read
business_idstringrequired
statusenum
GET/v1/review-requests/:reviewRequestId
scope: review_requests:read
POST/v1/review-requests/:reviewRequestId/send
scope: review_requests:write

Mark the request as sent. Use after your downstream sender (Twilio, Resend, etc.) delivers.

POST/v1/review-requests/:reviewRequestId/cancel
scope: review_requests:write

#Campaigns

Group routes by program — e.g. "Water heater install QR sticker run".

POST/v1/campaigns
scope: campaigns:write
GET/v1/campaigns
scope: campaigns:read
business_idstringrequired
GET/v1/campaigns/:campaignId
scope: campaigns:read
PATCH/v1/campaigns/:campaignId
scope: campaigns:write
POST/v1/campaigns/:campaignId/archive
scope: campaigns:write

#Team members

Optional: track per-tech or per-rep routes. Pair with a Route of type team_member to attribute reviews back to the person who served the customer.

POST/v1/businesses/:businessId/team-members
scope: team_members:write
GET/v1/businesses/:businessId/team-members
scope: team_members:read
GET/v1/team-members/:teamMemberId
scope: team_members:read
PATCH/v1/team-members/:teamMemberId
scope: team_members:write
POST/v1/team-members/:teamMemberId/archive
scope: team_members:write

#Webhooks

Subscribe a URL to receive POSTs when events happen. Payloads are signed Stripe-style.

POST/v1/webhook-endpoints
scope: webhooks:write
{
  "business_id": "biz_123",
  "url": "https://example.com/api/signalroute-webhook",
  "events": [
    "private_feedback.created",
    "rating.submitted",
    "public_click.created",
    "review_request.sent"
  ]
}

The response includes signing_secret exactly once. Subsequent reads omit it.

GET/v1/webhook-endpoints
scope: webhooks:read
GET/v1/webhook-endpoints/:webhookEndpointId
scope: webhooks:read
PATCH/v1/webhook-endpoints/:webhookEndpointId
scope: webhooks:write
DELETE/v1/webhook-endpoints/:webhookEndpointId
scope: webhooks:write
POST/v1/webhook-endpoints/:webhookEndpointId/test
scope: webhooks:write

Send a synthetic ping event to confirm receivers handle our signature scheme.

Payload shape

{
  "id": "evt_123",
  "type": "private_feedback.created",
  "created_at": "2026-05-04T12:00:00.000Z",
  "data": {
    "id": "fb_123",
    "business_id": "biz_123",
    "route_id": "route_123",
    "session_id": "sess_123",
    "rating": 2,
    "message": "...",
    "status": "new"
  }
}

Headers

SignalRoute-Signature: t=1777905600,v1=abcdef123456...
SignalRoute-Event-Id: evt_123

Verifying signatures (HMAC SHA-256)

import crypto from "node:crypto"

export function verifySignalRouteSignature(opts: {
  rawBody: string                // raw request body string
  signatureHeader: string        // "t=...,v1=..."
  secret: string                 // whsec_… from endpoint creation
  toleranceSec?: number          // default 300
}): boolean {
  const parts = Object.fromEntries(
    opts.signatureHeader.split(",").map(p => p.split("=") as [string,string])
  )
  const t = Number(parts.t)
  const v1 = parts.v1
  if (!t || !v1) return false
  if (Math.abs(Math.floor(Date.now()/1000) - t) > (opts.toleranceSec ?? 300)) return false
  const expected = crypto
    .createHmac("sha256", opts.secret)
    .update(`${t}.${opts.rawBody}`)
    .digest("hex")
  return crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))
}

Event types

  • business.created
  • business.updated
  • route.created
  • route.updated
  • route.archived
  • session.started
  • rating.submitted
  • private_feedback.created
  • private_feedback.updated
  • private_feedback.resolved
  • public_click.created
  • review_request.created
  • review_request.scheduled
  • review_request.sent
  • review_request.failed
  • review_request.opened
  • review_request.completed
  • team_member.created
  • team_member.updated
  • report.generated

Retries & status

We POST with a 5s timeout. Non-2xx or network errors increment failure_count. After 5 consecutive failures the endpoint flips to failing. A successful delivery resets the counter.

#Integration connections

Bookkeeping resource for native integrations (Housecall Pro, Jobber, ServiceTitan, Square, Stripe, QuickBooks, GBP, GoHighLevel, WordPress, Webflow, Zapier, Make, n8n). Stores per-tenant settings and credentials so dashboard UIs stay in sync.

POST/v1/integration-connections
scope: integrations:write
{
  "business_id": "biz_123",
  "provider": "housecall_pro",
  "status": "active",
  "settings": {
    "send_after_job_completed": true,
    "delay_minutes": 120,
    "route_id": "route_123",
    "channel": "sms"
  }
}
GET/v1/integration-connections
scope: integrations:read
GET/v1/integration-connections/:integrationConnectionId
scope: integrations:read
PATCH/v1/integration-connections/:integrationConnectionId
scope: integrations:write
DELETE/v1/integration-connections/:integrationConnectionId
scope: integrations:write

#Analytics

GET/v1/analytics/summary
scope: analytics:read
business_idstringrequired
start_dateYYYY-MM-DDrequired
end_dateYYYY-MM-DDrequired
{
  "business_id": "biz_123",
  "period": { "start_date": "2026-05-01", "end_date": "2026-05-31" },
  "metrics": {
    "route_views": 428,
    "ratings_submitted": 133,
    "average_rating": 4.62,
    "positive_ratings": 119,
    "private_feedback_count": 14,
    "public_clicks": 93,
    "google_clicks": 81,
    "facebook_clicks": 8,
    "yelp_clicks": 4,
    "resolved_feedback_count": 9,
    "review_protection_score": 82
  }
}

#Reports

POST/v1/reports/monthly
scope: reports:read
{ "business_id": "biz_123", "month": "2026-05", "format": "pdf" }

Returns a queued report. Workers flip status to generated and set download_url.

GET/v1/reports
scope: reports:read
business_idstringrequired
GET/v1/reports/:reportId
scope: reports:read

#Agency clients

Thin wrapper around businesses for agency workflows. Creating an agency client spins up a new client organization and a business under it, linking the agency.

POST/v1/agency-clients
scope: agency_clients:write
{
  "agency_organization_id": "org_agency_123",
  "business": {
    "name": "Main Street Auto Repair",
    "slug": "main-street-auto",
    "industry": "auto_repair",
    "website_url": "https://mainstreetauto.example",
    "email": "owner@example.com",
    "phone": "555-555-5555",
    "timezone": "America/New_York"
  }
}
GET/v1/agency-clients
scope: agency_clients:read
GET/v1/agency-clients/:agencyClientId
scope: agency_clients:read
PATCH/v1/agency-clients/:agencyClientId
scope: agency_clients:write
GET/v1/agency-clients/:agencyClientId/summary
scope: agency_clients:read

Compact month-to-date summary for the dashboard.

#Zapier-friendly endpoints

Slim, paginated-free dropdown endpoints for Zapier / Make / n8n integration UIs, plus the auth-test (/v1/me).

GET/v1/zapier/businesses
scope: businesses:read
GET/v1/zapier/routes
scope: routes:read
business_idstringrequired

#API keys

Session auth required. Plaintext is returned exactly once at create time.

POST/v1/api-keys
session only
{
  "organization_id": "org_123",
  "name": "Zapier production",
  "scopes": ["review_requests:write", "businesses:read", "routes:read"],
  "livemode": true
}
GET/v1/api-keys
session only
organization_idstringrequired
DELETE/v1/api-keys/:apiKeyId
session only

Soft-revokes the key. The key cannot be reactivated.

#/v1/me — auth test

Echoes the caller. Use it from Zapier to validate an API key at connection time.

GET/v1/me
any auth

Need an integration we don't list yet? Email bw@wadesinc.io or spin up a sandbox account and try the API today.