Surmado API Integration Guide
20 min read
TL;DR
Create an API key, generate AI Visibility / Site Audit / Strategy reports asynchronously, and receive results via webhooks or polling. Authenticate with Bearer token or X-API-Key header. Each report costs 1 credit. Results arrive as PDF, PPTX, and structured data.
Surmado API Integration Guide
Everything an external developer needs to create an API key, generate reports, and plug Surmado into automated workflows.
This guide is intentionally opinionated. It documents the recommended integration path, not every endpoint.
What This API Does
Surmado generates asynchronous intelligence reports for a brand:
- AI Visibility (
signal) — How AI models perceive and recommend your brand - Site Audit (
scan) — Competitive website analysis - Strategy (
solutions) — AI-powered strategic advisory
Each report costs 1 credit and takes 2—10 minutes to generate. Results come back as PDF (all products), PPTX (AI Visibility only), and structured data.
Getting Started
1. Create Your API Key
- Sign in at app.surmado.com
- Go to Settings > API Keys
- Click Create New API Key
- Copy the key immediately — it is only shown once
Your key looks like: sur_live_YOUR_API_KEY
Store it somewhere secure. Treat it like a password.
2. Base URL
https://api.surmado.com
All endpoints use the /v1 prefix:
https://api.surmado.com/v1/reports/signal
3. Authentication
Include your key in every request using either header:
Authorization: Bearer sur_live_YOUR_API_KEY
or:
X-API-Key: sur_live_YOUR_API_KEY
4. Verify Your Key
This endpoint is free and doesn’t consume credits:
curl https://api.surmado.com/v1/test-auth \
-H "Authorization: Bearer sur_live_YOUR_API_KEY"
{
"authenticated": true,
"org_id": "org_abc123",
"org_name": "Acme Corp",
"credits": 47,
"message": "Authentication successful! Your API key is working correctly."
}
Quick Start: Create a Report in One Call
The simplest path is to send brand_name directly when creating a report. Surmado will create the brand automatically if it doesn’t exist.
curl -X POST https://api.surmado.com/v1/reports/scan \
-H "Authorization: Bearer sur_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_name": "Acme Corp",
"url": "https://www.acme.com",
"email": "founder@acme.com"
}'
{
"report_id": "rpt_abc123xyz",
"product": "scan",
"status": "queued",
"brand_slug": "acme_corp",
"brand_name": "Acme Corp",
"brand_created": true,
"note": "brand_slug created - prevents duplicates from name variations",
"credits_used": 1,
"request_id": "a1b2c3d4e5",
"created_at": "2025-11-15T22:35:00Z"
}
Response truncated for clarity. See the API reference for the full schema.
Save the brand_slug from the response and reuse it in future requests to avoid creating duplicate brands. The note field explains when a brand was auto-created.
Recommended Integration Pattern
The quick start above skips brand management by sending brand_name inline. For production integrations that create reports for many brands, separate those concerns with this three-step pattern:
POST /v1/brands/ensure— get or create a brand (idempotent)POST /v1/reports/{product}— create a report usingbrand_slugGET /v1/reports/{report_id}or receive a webhook
This keeps your integration idempotent and avoids brand duplication from name variations. You can pass request_id as a query parameter on report creation (e.g. ?request_id=your-unique-id) to make retries safe — the API will return the existing report instead of creating a duplicate. If omitted, one is auto-generated from a hash of the request body with 1-minute granularity (so identical requests within the same minute are deduplicated automatically). request_id uniqueness is scoped per organization.
For production retries, always send your own
request_id; do not rely on auto-generated deduplication semantics. The auto-generated hash is a convenience for accidental double-submits, not a substitute for explicit idempotency keys in retry loops.
Brands
Ensure a Brand Exists
POST /v1/brands/ensure
curl -X POST https://api.surmado.com/v1/brands/ensure \
-H "Authorization: Bearer sur_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_name": "Acme Corp"
}'
{
"brand_slug": "acme_corp",
"brand_name": "Acme Corp",
"created_at": "2025-11-15T10:30:00Z",
"created": true
}
This endpoint returns 200 whether the brand already existed or was just created. The created field tells you which happened. Calling it multiple times with the same name returns the same brand_slug. Use brand_slug in all subsequent requests.
Brand names are normalized to slugs by lowercasing, replacing spaces with underscores, removing non-alphanumeric characters (except underscores), collapsing consecutive underscores, stripping leading/trailing underscores, and truncating to 64 characters. "Acme Corp" becomes acme_corp, "--Test Brand--" becomes test_brand.
If your organization has reached its brand limit, this endpoint returns 403 Forbidden.
Creating Reports
All report endpoints return 202 Accepted and process asynchronously.
AI Visibility (signal)
POST /v1/reports/signal
Required fields: brand_slug or brand_name, url, email, industry, location, persona, pain_points, brand_details, direct_competitors
keywords, business_scale, and product are optional. The example below includes them for completeness.
curl -X POST https://api.surmado.com/v1/reports/signal \
-H "Authorization: Bearer sur_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_slug": "acme_corp",
"url": "https://acme.com",
"email": "founder@acme.com",
"industry": "B2B SaaS",
"business_scale": "medium",
"location": "United States",
"product": "CRM software",
"persona": "Small business owners",
"pain_points": "Losing track of customer conversations",
"brand_details": "Simple CRM with email integration",
"direct_competitors": "HubSpot, Pipedrive",
"keywords": "CRM, sales tracking"
}'
Watch out:
pain_pointsmust be a string, not an arraydirect_competitorsmust be a comma-separated string, not an array
Site Audit (scan)
POST /v1/reports/scan
Required fields: brand_slug or brand_name, url, email
curl -X POST https://api.surmado.com/v1/reports/scan \
-H "Authorization: Bearer sur_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_slug": "acme_corp",
"url": "https://www.acme.com",
"email": "founder@acme.com",
"report_style": "executive",
"competitor_urls": [
"https://competitor1.com",
"https://competitor2.com"
]
}'
competitor_urls is an array of URLs (unlike direct_competitors on signal, which is a comma-separated string).
Strategy (solutions)
POST /v1/reports/solutions
Strategy supports two modes.
Standalone — provide your own business context:
Required fields: brand_slug or brand_name, email, business_story, decision, success, timeline, scale_indicator
curl -X POST https://api.surmado.com/v1/reports/solutions \
-H "Authorization: Bearer sur_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"brand_slug": "acme_corp",
"email": "founder@acme.com",
"business_story": "B2B SaaS CRM, 50 employees, $3M ARR",
"decision": "Should we build a mobile app or improve the web experience?",
"success": "40% engagement increase in 6 months",
"timeline": "Need decision in next 30 days",
"scale_indicator": "regional"
}'
From an existing AI Visibility report — pass the Intelligence Token from a completed Signal report:
Required fields: email, plus signal_token and/or scan_token
curl -X POST https://api.surmado.com/v1/reports/solutions \
-H "Authorization: Bearer sur_live_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "founder@acme.com",
"signal_token": "SIG-2025-11-XXXXX",
"scan_token": "SCN-2025-11-XXXXX"
}'
You can pass either or both Intelligence Tokens. Each feeds the corresponding report’s data into the Strategy analysis. When using signal_token and/or scan_token, brand_slug / brand_name is not required — the brand is inherited from the referenced Signal report.
Report Status and Downloads
Check Status
GET /v1/reports/{report_id}
curl https://api.surmado.com/v1/reports/rpt_abc123xyz \
-H "Authorization: Bearer sur_live_YOUR_API_KEY"
Status Values
| Status | Meaning |
|---|---|
queued | Accepted, waiting to start |
processing | Report is being generated |
completed | Finished, download URLs available |
failed | Generation failed, check error field |
cancelled | Report was cancelled |
Strategy reports that depend on a Signal or Scan report may also show intermediate statuses like waiting_on_signal, waiting_on_scan, or waiting_on_dependencies. Treat any status other than completed, failed, or cancelled as “still in progress.”
Completed Response
{
"report_id": "rpt_abc123xyz",
"product": "signal",
"status": "completed",
"brand_slug": "acme_corp",
"brand_name": "Acme Corp",
"token": "SIG-2025-11-A1B2C",
"download_url": "https://storage.googleapis.com/...",
"pptx_download_url": "https://storage.googleapis.com/...",
"created_at": "2025-11-15T22:35:00Z",
"completed_at": "2025-11-15T22:40:00Z"
}
Response truncated for clarity. See the API reference for the full schema.
The token field (e.g. SIG-2025-11-A1B2C, SCN-..., SOL-...) is the report’s Intelligence Token — a unique identifier populated on completion. It will be null while the report is still queued or processing. This is the value you pass as signal_token or scan_token when creating a Strategy report. See Structured Data & Intelligence Tokens for full schema details.
Download URLs
| Field | Format | Use |
|---|---|---|
download_url | Signed URL | PDF download |
pptx_download_url | Signed URL | PowerPoint download (when available) |
Signed URLs expire after approximately 15 minutes. If you need the file long-term, download and store it on your side. You can always fetch fresh URLs by calling the status endpoint again.
To list all reports for your organization, use GET /v1/reports (paginated).
Structured Report Data for Automation
For automation workflows (n8n, Make, Zapier, custom code), completed reports may include a public_intelligence object directly in the status response. When present, this contains structured report data you can parse, transform, and route into other systems — no separate download needed. If the field is absent, the intelligence data was not available for that report.
curl -s https://api.surmado.com/v1/reports/rpt_abc123xyz \
-H "Authorization: Bearer sur_live_YOUR_API_KEY" \
| jq '.public_intelligence'
Webhooks
For production integrations, webhooks are preferred over polling. Include webhook_url in your report request and Surmado will POST to that URL when the report completes or fails.
{
"brand_slug": "acme_corp",
"url": "https://acme.com",
"email": "founder@acme.com",
"webhook_url": "https://your-app.com/webhooks/surmado"
}
Webhook Payload (Completed)
The payload is a nested envelope with event, timestamp, and a report object:
{
"event": "report.completed",
"timestamp": "2025-11-17T06:00:00+00:00",
"report": {
"id": "rpt_abc123xyz",
"token": "SIG-2025-11-A1B2C",
"product": "signal",
"status": "completed",
"tier": "basic",
"data_url": "https://api.surmado.com/v1/reports/rpt_abc123xyz",
"pdf_url": "https://api.surmado.com/v1/reports/view/VIEW_TOKEN",
"summary": {
"business_name": "Acme Corp",
"contact_email": "founder@acme.com",
"presence_score": 72
}
}
}
data_urlpoints to the report status endpoint — call it with your API key to get signed download URLspdf_urlis a magic-link URL (valid ~30 days) that generates a fresh signed PDF on each access; this is a different URL from the signeddownload_urlon the REST status response, which expires in ~15 minutessummarycontains curated metrics extracted from the report (varies by product, may be absent). Signal summaries include metrics likepresence_score,authority_score, andcompetitive_rank. Scan summaries includeseo_score,performance_score, andcritical_issues. See the API reference for the full schema per product.
Note: Webhook payloads use
report.id, which is the same value asreport_idin REST responses. If you store report identifiers, normalize on one key name in your system.
Webhook Payload (Failed)
{
"event": "report.failed",
"timestamp": "2025-11-17T06:00:00+00:00",
"report": {
"id": "rpt_abc123xyz",
"token": "",
"product": "signal",
"status": "failed",
"tier": "basic",
"data_url": "https://api.surmado.com/v1/reports/rpt_abc123xyz",
"failure_reason": "Generation failed: Invalid URL format",
"credits_refunded": true
}
}
Failed reports are automatically refunded. The credits_refunded field confirms this.
Webhook Headers
Content-Type: application/json
X-Surmado-Signature: <hex HMAC-SHA256 signature>
X-Surmado-Event: report.completed
User-Agent: Surmado-Webhooks/1.0
The X-Surmado-Event value matches the event field in the payload body (report.completed, report.failed, etc.).
Verifying Webhook Signatures
Important: The signature is computed over a canonicalized JSON string (compact separators, sorted keys) — not the raw HTTP request body. Verifying against the raw bytes will fail. Parse the received body and re-serialize it the same way:
import hashlib
import hmac
import json
def verify_webhook(raw_body: bytes, signature_header: str, webhook_secret: str) -> bool:
parsed = json.loads(raw_body)
canonical = json.dumps(parsed, separators=(",", ":"), sort_keys=True)
expected = hmac.new(
webhook_secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
const crypto = require("crypto");
function verifyWebhook(rawBody, signatureHeader, webhookSecret) {
const canonical = JSON.stringify(
JSON.parse(rawBody),
(key, value) =>
value && typeof value === "object" && !Array.isArray(value)
? Object.keys(value).sort().reduce((o, k) => { o[k] = value[k]; return o; }, {})
: value
);
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(canonical)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(signatureHeader, "hex")
);
}
Webhook Behavior
- HTTPS required for your endpoint (except testing tools like webhook.site)
- Timeout: Your endpoint must respond within 30 seconds or the attempt is treated as failed
- Retries: 3 attempts with exponential backoff (5s, 25s, 125s) on 5xx or timeout. No retry on 4xx.
- Delivery order is not guaranteed — if you create multiple reports, their webhooks may arrive in any order
- Your endpoint should return
2xxquickly and process the payload asynchronously - Deduplicate by
report.idin case of retries
Testing Webhooks
Use webhook.site during development. Create a temporary URL there and pass it as your webhook_url to see payloads arrive in real time.
Error Handling
Status Codes
| Code | Meaning |
|---|---|
200 | Success |
201 | Created |
202 | Accepted and queued |
400 | Bad request |
401 | Unauthorized — check your API key |
402 | Insufficient credits — top up at app.surmado.com/billing |
403 | Forbidden — e.g. brand limit reached |
404 | Not found |
422 | Validation error — check field names and types |
429 | Rate limit exceeded — slow down |
500 | Internal error — retry after a moment |
Common Error Shapes
Error responses vary across endpoints. Don’t build a parser that assumes one canonical shape — instead, always check for a detail field and read error and message defensively.
Insufficient credits (402):
{
"detail": {
"error": "INSUFFICIENT_CREDITS",
"have": 0,
"need": 1,
"message": "Bundle requires 1 credits but only 0 available."
}
}
Validation or not-found errors (400, 404, 422):
{
"detail": {
"error": "brand_not_found",
"brand_slug": "nonexistent_brand"
}
}
Some endpoints nest the error one level deeper:
{
"detail": {
"error": {
"code": "brand_reference_missing",
"message": "Either brand_slug or brand_name must be provided"
}
}
}
Report-level failure (returned on the status response, not the creation call):
{
"report_id": "rpt_abc123xyz",
"status": "failed",
"error": "Generation failed: Invalid URL format",
"error_code": "GENERATION_FAILED",
"error_details": "..."
}
Error codes use mixed casing depending on the endpoint — some are lowercase snake_case (brand_not_found), some are SCREAMING_SNAKE (GENERATION_FAILED), and some are plain English (Insufficient credits). Parse the error field as a string; don’t match on exact casing.
Common Mistakes
- Missing the
/v1prefix in the URL - Sending
pain_pointsas an array instead of a string - Sending
direct_competitorsas an array instead of a string - Using a non-HTTPS
webhook_url - Creating brands repeatedly instead of reusing
brand_slug
Rate Limits
20 requests per minute, per organization
All API keys belonging to the same organization share this quota. When you exceed it, the API returns 429 Too Many Requests with a Retry-After: 60 header. If you need higher limits for bulk operations, contact hi@surmado.com.
Production Checklist
- Create your API key at app.surmado.com
- Verify it works with
GET /v1/test-auth - Use
POST /v1/brands/ensureonce per customer brand, then reusebrand_slug - Create reports with
webhook_urlfor completion notifications - Download and store report files on your side — signed URLs expire after ~15 minutes
- Handle
402errors by checking credit balance before bulk runs - For production integrations, always send your own
request_idand deduplicate webhooks byreport.id; do not rely solely on automatic retry or auto-generated deduplication behavior
Interactive API Docs
Full endpoint schemas with try-it-out functionality are available at:
https://help.surmado.com/docs/api-reference/
-> Related: Structured Data & Intelligence Tokens | Zapier Integration | Make Integration | n8n Integration
Was this helpful?
Thanks for your feedback!
Have suggestions for improvement?
Tell us moreHelp Us Improve This Article
Know a better way to explain this? Have a real-world example or tip to share?
Contribute and earn jobs:
- Submit: Get 1 free job (AI Visibility, Site Audit, or Strategy)
- If accepted: Get an additional free job (2 total)
- Plus: Byline credit on this article