Skip to main content

Surmado API Integration Guide

20 min read

20 min read Level: Intermediate For: Developer / Agency

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

  1. Sign in at app.surmado.com
  2. Go to Settings > API Keys
  3. Click Create New API Key
  4. 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.


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:

  1. POST /v1/brands/ensure — get or create a brand (idempotent)
  2. POST /v1/reports/{product} — create a report using brand_slug
  3. GET /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_points must be a string, not an array
  • direct_competitors must 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

StatusMeaning
queuedAccepted, waiting to start
processingReport is being generated
completedFinished, download URLs available
failedGeneration failed, check error field
cancelledReport 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

FieldFormatUse
download_urlSigned URLPDF download
pptx_download_urlSigned URLPowerPoint 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_url points to the report status endpoint — call it with your API key to get signed download URLs
  • pdf_url is a magic-link URL (valid ~30 days) that generates a fresh signed PDF on each access; this is a different URL from the signed download_url on the REST status response, which expires in ~15 minutes
  • summary contains curated metrics extracted from the report (varies by product, may be absent). Signal summaries include metrics like presence_score, authority_score, and competitive_rank. Scan summaries include seo_score, performance_score, and critical_issues. See the API reference for the full schema per product.

Note: Webhook payloads use report.id, which is the same value as report_id in 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 2xx quickly and process the payload asynchronously
  • Deduplicate by report.id in 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

CodeMeaning
200Success
201Created
202Accepted and queued
400Bad request
401Unauthorized — check your API key
402Insufficient credits — top up at app.surmado.com/billing
403Forbidden — e.g. brand limit reached
404Not found
422Validation error — check field names and types
429Rate limit exceeded — slow down
500Internal 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 /v1 prefix in the URL
  • Sending pain_points as an array instead of a string
  • Sending direct_competitors as 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

  1. Create your API key at app.surmado.com
  2. Verify it works with GET /v1/test-auth
  3. Use POST /v1/brands/ensure once per customer brand, then reuse brand_slug
  4. Create reports with webhook_url for completion notifications
  5. Download and store report files on your side — signed URLs expire after ~15 minutes
  6. Handle 402 errors by checking credit balance before bulk runs
  7. For production integrations, always send your own request_id and deduplicate webhooks by report.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

Help 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
Contribute to This Article