Trustburn API

Manage your business listings and read Trustburn review data from your own systems. JSON over HTTPS, one Bearer key, no SDK required.

Overview

The Trustburn API lets an integration do two things:

  • Manage its own listings. Create and edit the business profiles your account owns, upload a logo and photos, read the reviews those listings received.
  • Read public review data. Look up any active Trustburn company by id or domain, read its reviews, and read its rating statistics.

It exists for the people who were doing this by hand: agencies and multi-location businesses running dozens or hundreds of profiles, and product teams that want a company's Trustburn rating inside their own screens. Creating and editing listings through the API costs nothing.

What the API deliberately does not do: it cannot submit reviews. There is no write path for review content, for anybody, and there will not be one — a review that a machine can post is not a review anyone should trust.

Base URL for every call:

https://trustburn.com/api/v1

All responses are JSON. All timestamps are UTC, ISO 8601 with a literal Z.

Getting a key

Sign in to your Trustburn business account and open API keys in the cabinet. Name the key after the thing that will use it, press Create key, and copy the value.

The key is shown once. We store only its SHA-256 hash, so nobody — including us — can read it back. Lost it? Revoke it and create another.

A key looks like this:

tb_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6

The first 16 characters (tb_live_a1b2c3d4) are the key's public prefix — that is what the cabinet shows in the list and what is safe to put in a log line. The rest is the secret.

Your first call should be GET /me: it confirms the key works and tells you the scopes and quotas it carries.

Authentication

Send the key as a Bearer token on every request:

curl -s https://trustburn.com/api/v1/me \
  -H "Authorization: Bearer $TB_KEY"

Browser session cookies are not accepted on /api/v1/*. A key is the only way in, and a missing, malformed, unknown or revoked key answers 401 unauthorized.

Scopes

Every key carries a set of scopes. New keys get all four. A call that needs a scope the key lacks answers 403 insufficient_scope and names the scope it wanted.

ScopeGrants
listings:readRead your own listings and their reviews; read the category and geography reference data.
listings:writeCreate and edit your listings; upload a logo and photos; delete a photo.
companies:readRead any active company's public card and its rating statistics.
reviews:readRead any active company's reviews.

Rate limits & quotas

LimitDefaultWhat you get when you cross it
Requests per minute, per key60429 rate_limited with retry_after and a Retry-After header
Requests per minute, per IP120 (burst 60)429 rate_limited from the edge, Retry-After: 60
Listings created per day, per key200429 daily_limit_reached
Company reads per day, per key1000429 daily_read_limit_reached
Photos per /photos call10 URLsrejected with 422 validation_failed
Owner photos in one gallery12extra URLs come back in photos_rejected as gallery_full
Domains per /companies/lookup100422 validation_failed
Review paging depthpage × per_page ≤ 10000422 validation_failed
Categories per listing3422 validation_failed

Every response carries the current state of your budget:

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute for this key.
X-RateLimit-RemainingRequests left in the current 60-second window, after this one.
X-Daily-Reads-LimitCompany reads allowed per UTC day. 0 means unlimited.
X-Daily-Reads-RemainingCompany reads left today, after this one.
Retry-AfterSeconds to wait. Sent with 429.

What actually costs quota

  • Days are UTC days, not calendar days in your timezone.
  • Only successful calls count. A validation failure, a 404, a 409 on a taken domain and a dry_run create are all free.
  • One POST /companies/lookup costs one read no matter how many of the 100 domains it carries. That is why it returns the compact card.
  • GET /companies/{id}, GET /companies?domain= and GET /companies/{id}/stats send ETag and Cache-Control: private, max-age=300. Store the ETag, send it back as If-None-Match, and a 304 costs you nothing.
  • Reading your own listings and their reviews does not touch the company-read quota at all.
Cloudflare may hand you the ETag weakened to W/"…". Send back whatever you received — both forms are accepted.

Errors

Every failure under /api/v1/* uses one envelope. code is stable and safe to branch on; message is for a human reading a log.

{
  "error": {
    "code": "validation_failed",
    "message": "request validation failed",
    "fields": {
      "name": "must be 2 to 150 characters",
      "website": "required"
    }
  }
}
CodeHTTPMeans
unauthorized401No key, a malformed key, or a key that is unknown, revoked, or belongs to a disabled account.
insufficient_scope403Valid key, wrong scope. The response carries required_scope.
forbidden403Blocked account or IP.
not_found404Unknown endpoint, or a resource this key cannot see. Listings you do not own answer 404 on purpose, so ids cannot be probed.
method_not_allowed405The path exists, but not for that HTTP method.
domain_taken409Somebody else already has a listing on that domain. The response names only its id and public URL.
bad_request400The request could not be understood.
invalid_json400The body was sent as JSON but is not valid JSON.
validation_failed422One or more fields rejected. fields maps field name to reason — all bad fields at once, not one per request.
domain_unreachable422The domain does not answer over HTTP(S) on a public address.
content_rejected422The site does not qualify for a listing (parked, empty, unreadable). reason says which.
no_agency_credits402No agency listing-years left. The response carries checkout and the full pricing ladder.
upgrade_conflict409Two upgrades for the same account collided. Safe to retry.
checkout_unavailable502The payment session could not be created. Retry.
unknown_format422The uploaded review export matched none of the known column layouts. The response carries the file’s real headers; send them back as mapping.
payload_too_large413A review export over 10 MB, or more than 5,000 rows in one transfer.
transfer_not_confirmable409That transfer is not awaiting confirmation — usually it was already confirmed.
nothing_to_import409Every row in the transfer was a duplicate or was rejected.
transfer_not_undoable409Only a completed transfer can be withdrawn.
undo_window_closed409The 30-day self-service undo window has passed. Contact support.
transfer_daily_limit429That listing has already opened its three review transfers in the last 24 hours.
google_not_connected409You sent mode:"google" for a listing with no usable Google Business Profile connection. Only the owner can make one, in their cabinet — OAuth needs a browser. configured in the response says whether our own Google access is live yet; the message is written to be shown to them.
google_no_location409The owner connected Google but has not yet said which of their Google locations this listing is.
url_not_supported_for_source422You sent mode:"url" for a platform we cannot fetch. The message is that platform’s own reason; url_capable_sources lists the ones that do work.
import_failed502The review index refused every write. Nothing was imported; quote the transfer id to support.
review_index_unavailable503The review index was unreachable. Nothing changed; retry.
rate_limited429Too many requests this minute — from your key's window or from the edge.
daily_limit_reached429This key has created its allowance of listings for the current UTC day.
daily_read_limit_reached429This key has spent its company-read allowance for the current UTC day.
create_failed500The listing passed every check but could not be written.
internal_error500Something failed on our side. Nothing about the failure is exposed.
error4xx/5xxFallback for a status with no more specific code.

Endpoints

Every example below assumes your key is in $TB_KEY. Responses are abridged where a full list would run for pages; the complete shapes are in the API reference.

Account

GET/v1/meany scope

Confirms the key is live and reports the account it belongs to, the key's scopes, and every quota with today's usage already counted. The cheapest health check there is.

Request
curl -s https://trustburn.com/api/v1/me \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "account": {
    "id": 4210,
    "name": "Acme Agency",
    "email_masked": "ja***@acme.com",
    "companies": 11
  },
  "key": {
    "id": 11,
    "name": "Agency integration",
    "prefix": "tb_live_a1b2c3d4",
    "scopes": ["listings:read", "listings:write", "companies:read", "reviews:read"],
    "created_at": "2026-09-08T14:27:07Z",
    "last_used_at": null
  },
  "limits": {
    "rate_per_min": 60,
    "listings_per_day": 200,
    "listings_created_today": 0,
    "reads_per_day": 1000,
    "reads_today": 0
  },
  "api_version": "v1"
}

Reference data

Closed vocabularies. A listing's categories, country, state_id and city_id must come from here.

GET/v1/categorieslistings:read

The whole category taxonomy, sorted by slug. slug is the stable identifier and the one that appears in public URLs.

Request
curl -s https://trustburn.com/api/v1/categories \
  -H "Authorization: Bearer $TB_KEY"
Response
[
  { "slug": "accounting", "name": "Accounting" },
  { "slug": "airlines-aviation", "name": "Airlines/aviation" },
  { "slug": "seo-services", "name": "SEO Services" }
]
GET/v1/geo/countrieslistings:read

ISO 3166-1 alpha-2 codes accepted by a listing's country.

Request
curl -s https://trustburn.com/api/v1/geo/countries \
  -H "Authorization: Bearer $TB_KEY"
Response
[
  { "code": "AF", "name": "Afghanistan" },
  { "code": "UA", "name": "Ukraine" },
  { "code": "US", "name": "United States" }
]
GET/v1/geo/states?country=USlistings:read
Request
curl -s "https://trustburn.com/api/v1/geo/states?country=US" \
  -H "Authorization: Bearer $TB_KEY"
Response
[
  { "id": 1399, "name": "Delaware" },
  { "id": 1400, "name": "Alaska" }
]
GET/v1/geo/cities?country=&state=&q=listings:read

Cities with a population over 10,000 — the same list the business cabinet offers, on purpose: a listing placed in a city outside that list could not afterwards be edited in the cabinet without moving it. q filters by name prefix and caps the result at 50.

Request
curl -s "https://trustburn.com/api/v1/geo/cities?country=US&state=1407&q=den" \
  -H "Authorization: Bearer $TB_KEY"
Response
[
  { "id": 115237, "name": "Denison" },
  { "id": 115247, "name": "Denton" }
]

Listings

Listings are the business profiles your account owns. A listing you do not own answers 404.

GET/v1/listingslistings:read

Your listings, newest first. page (default 1) and per_page (default 50, max 100); out-of-range values are clamped, not rejected.

Request
curl -s "https://trustburn.com/api/v1/listings?per_page=2" \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "items": [
    {
      "id": 25594885,
      "name": "Hafisa Magezi Foundation",
      "website": "https://hafisamagezi.org",
      "domain": "hafisamagezi.org",
      "public_url": "https://trustburn.com/reviews/hafisamagezi-org",
      "phone": "+256 393 250 400",
      "email": "[email protected]",
      "address": "Kyebando, Erisa, Kawempe Division, Kampala, 214661, UG",
      "country": "UG",
      "state": { "id": 396, "name": "Central Region" },
      "city": { "id": 110903, "name": "Kampala" },
      "categories": [
        { "slug": "philanthropy", "name": "Philanthropy" }
      ],
      "description": "…",
      "logo_url": "https://trustburn.com/api/images/company-pic/company-pic-….png",
      "photos": [
        { "id": 759204, "url": "https://trustburn.com/api/images/company-review-pic/web-….jpg" }
      ],
      "status": "active",
      "owner_verified": null,
      "rating": { "avg": 0, "count": 0 },
      "created_at": "2026-09-07T16:35:29Z",
      "updated_at": null
    }
  ],
  "page": 1,
  "per_page": 2,
  "total": 11
}
updated_at is always null today. The field is in the response so your parser will not have to change when it starts carrying a value.
POST/v1/listingslistings:write

Creates a listing and answers 201 with the full card. Required: name, website, phone, email, country. Optional: address, description, state_id/state, city_id/city, categories (up to 3 slugs), logo_url, photos (up to 10 URLs).

Idempotent by domain. If your account already owns a listing on that domain, nothing is created — the existing card comes back with 200 and "existing": true. Retrying a create whose response you lost is therefore safe. If somebody else owns the domain you get 409 domain_taken.

Order of checks: field validation → domain already listed → daily quota → domain reachable → content gate → write. The cheap checks run first, so a stream of malformed bodies costs no outbound requests.

"dry_run": true runs every check and returns the normalized fields under would_create without writing anything and without spending quota.

Request
curl -s -X POST https://trustburn.com/api/v1/listings \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Northwind Locks",
        "website": "https://northwindlocks.com",
        "phone": "+1 415 555 0134",
        "email": "[email protected]",
        "country": "US",
        "city": "San Francisco",
        "categories": ["security-and-investigations"],
        "dry_run": true
      }'
Response
{
  "ok": true,
  "would_create": {
    "name": "Northwind Locks",
    "website": "https://northwindlocks.com",
    "domain": "northwindlocks.com",
    "phone": "+1 415 555 0134",
    "email": "[email protected]",
    "address": "",
    "country": "US",
    "state": { "id": 1416, "name": "California" },
    "city": { "id": 122543, "name": "San Francisco" },
    "categories": [
      { "slug": "security-and-investigations", "name": "Security and investigations" }
    ],
    "description": "",
    "logo_url": null,
    "photos": []
  }
}
GET/v1/listings/{id}listings:read
Request
curl -s https://trustburn.com/api/v1/listings/25594885 \
  -H "Authorization: Bearer $TB_KEY"
PATCH/v1/listings/{id}listings:write

Partial update: omitted fields keep their current values. The current state is loaded first and your body applied on top, so categories, social links and opening hours survive a partial PATCH.

website cannot be changed — the domain is the listing's identity; sending it answers 422. Changing country clears state_id/city_id unless the same request supplies new ones.

Request
curl -s -X PATCH https://trustburn.com/api/v1/listings/25594885 \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"description": "Licensed 24/7 locksmith serving the Bay Area."}'
POST/v1/listings/{id}/logolistings:write

Either a file (multipart/form-data, field file) or a URL ({"url": "…"}). Same format, size and blank-image checks as the cabinet upload; resized to 200×200. An existing logo is replaced. Returns the updated card.

Request
curl -s -X POST https://trustburn.com/api/v1/listings/25594885/logo \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://northwindlocks.com/logo.png"}'

# or a file:
curl -s -X POST https://trustburn.com/api/v1/listings/25594885/logo \
  -H "Authorization: Bearer $TB_KEY" \
  -F "[email protected]"
POST/v1/listings/{id}/photoslistings:write

Either one uploaded file or up to 10 URLs per call. The gallery holds 12 owner photos in total. Photos that fail a check are reported in photos_rejected on the returned card — the call itself still succeeds.

Request
curl -s -X POST https://trustburn.com/api/v1/listings/25594885/photos \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://northwindlocks.com/img/van.jpg"]}'
Response
{
  "id": 25594885,
  "…": "…",
  "photos_rejected": [
    { "url": "https://northwindlocks.com/img/van.jpg", "reason": "gallery_full" }
  ]
}
DELETE/v1/listings/{id}/photos/{photoId}listings:write

photoId is the id from the card's photos[].

Request
curl -s -X DELETE https://trustburn.com/api/v1/listings/25594885/photos/759204 \
  -H "Authorization: Bearer $TB_KEY"
Response
{ "ok": true, "deleted": { "id": 759204 } }
GET/v1/listings/{id}/reviewslistings:read

Reviews of a listing you own. Needs only listings:read and does not spend the company-read quota. per_page default 20, max 100. For owner replies, helpful counts, sorting and rating filters use GET /v1/companies/{id}/reviews.

Request
curl -s "https://trustburn.com/api/v1/listings/25594885/reviews?per_page=2" \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "items": [
    {
      "id": "ok9OJ5EBI73yNbb31zYq",
      "rating": 5,
      "title": "Fast and fair",
      "text": "Out at 2am, done in twenty minutes.",
      "author_name": "abhilash",
      "created_at": "2024-08-06T10:49:23Z"
    }
  ],
  "page": 1,
  "per_page": 2,
  "summary": { "avg": 5, "count": 1 },
  "total": 1
}

Companies & reviews

Read-only access to any active Trustburn company — not only yours. These are the calls that spend the daily read quota.

GET/v1/companies/{id}companies:read

The public card: the same facts an anonymous visitor sees on the company page. A company with no public page answers 404, same as an unknown id.

The company's contact email is deliberately not included. It is visible on the page, one at a time; a hundred of them in one JSON response is a different thing.

verified means ownership was really proved — the same test that draws the Verified badge. A claim that is only pending shows up as claimed, not verified.

Request
curl -s https://trustburn.com/api/v1/companies/6 \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "id": 6,
  "name": "Semalt",
  "website": "https://semalt.com",
  "domain": "semalt.com",
  "public_url": "https://trustburn.com/reviews/semalt",
  "description": "Professional SEO services, keyword ranking monitoring service, competitor analysis. Free plans.",
  "phone": "+1-855-979-6663",
  "address": "Pavla Skoropadskoho str. 9A, Kyiv, Ukraine",
  "country": { "code": "UA", "name": "Ukraine" },
  "state": { "id": 4671, "name": "Kyivska oblast" },
  "city": { "id": 109897, "name": "Kyiv" },
  "categories": [ { "slug": "seo-services", "name": "SEO Services" } ],
  "logo_url": "https://trustburn.com/api/images/company-pic/8-6-company.png",
  "rating": {
    "avg": 5,
    "count": 499,
    "distribution": { "1": 0, "2": 0, "3": 0, "4": 0, "5": 499 }
  },
  "verified": true,
  "claimed": true,
  "created_at": "2020-09-30T22:00:00Z",
  "attribution": {
    "text": "Reviews via Trustburn",
    "url": "https://trustburn.com/reviews/semalt"
  }
}
GET/v1/companies?domain=companies:read

Same card, found by domain. The domain is normalized exactly the way listing creation normalizes it, so https://WWW.Example.com/path and example.com find the same card. When several active cards share a domain the lowest id wins and duplicates reports how many were found.

Request
curl -s "https://trustburn.com/api/v1/companies?domain=semalt.com" \
  -H "Authorization: Bearer $TB_KEY" \
  -H 'If-None-Match: "9d816d67ea958f9a5348271e022c6b88"'
POST/v1/companies/lookupcompanies:read

Up to 100 domains in one call, costing one unit of the daily read quota — which is why it returns the compact card and not the full one. For the full card follow up with GET /v1/companies/{id}, one unit each.

The response mirrors the request: one entry per submitted domain, in the order sent, with company: null where nothing was found.

Request
curl -s -X POST https://trustburn.com/api/v1/companies/lookup \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domains": ["semalt.com", "example-not-here-xyz.com"]}'
Response
{
  "items": [
    {
      "domain": "semalt.com",
      "company": {
        "id": 6,
        "name": "Semalt",
        "domain": "semalt.com",
        "public_url": "https://trustburn.com/reviews/semalt",
        "rating": { "avg": 5, "count": 499 },
        "verified": true,
        "claimed": true,
        "attribution": {
          "text": "Reviews via Trustburn",
          "url": "https://trustburn.com/reviews/semalt"
        }
      }
    },
    { "domain": "example-not-here-xyz.com", "company": null }
  ]
}
GET/v1/companies/{id}/reviewsreviews:read

Query: page, per_page (default 20, max 50 — larger values are clamped, not rejected), sort (newest, oldest, highest, lowest), min_rating and max_rating (1–5).

Paging is capped at page × per_page ≤ 10000; deeper answers 422. Narrow with the rating filters instead.

verified on a review means invited by the business — the author came in through the company's own review invitation. There is no other verification signal on a review. total respects the rating filters; summary is always the company's whole review body.

Request
curl -s "https://trustburn.com/api/v1/companies/6/reviews?per_page=2&sort=newest" \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "items": [
    {
      "id": "ok9OJ5EBI73yNbb31zYq",
      "rating": 5,
      "title": "Brett Walton is professional",
      "text": "Brett Walton is professional",
      "author_name": "abhilash",
      "created_at": "2024-08-06T10:49:23Z",
      "verified": false,
      "helpful_count": 0,
      "owner_reply": null,
      "url": "https://trustburn.com/review/ok9OJ5EBI73yNbb31zYq",
      "lang": "en"
    }
  ],
  "summary": { "avg": 5, "count": 499 },
  "pagination": { "page": 1, "per_page": 2, "total": 499, "total_pages": 250 },
  "attribution": {
    "text": "Reviews via Trustburn",
    "url": "https://trustburn.com/reviews/semalt"
  }
}
GET/v1/companies/{id}/statscompanies:read

Rating, star distribution, a 12-month series and the newest review's timestamp — one call instead of paging the whole review body. Months with no reviews are present with zeros.

Request
curl -s https://trustburn.com/api/v1/companies/6/stats \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "company_id": 6,
  "rating": { "avg": 5, "count": 499 },
  "distribution": { "1": 0, "2": 0, "3": 0, "4": 0, "5": 499 },
  "by_month": [
    { "month": "2026-05", "count": 0, "avg": 0 },
    { "month": "2026-06", "count": 1, "avg": 5 }
  ],
  "last_review_at": "2026-06-29T16:53:34Z"
}

Agency upgrades

Agencies that manage many listings buy listing-years in bulk. One listing-year gives one listing, for one year:

  • a dofollow link to the business’s website on its public Trustburn page — a real link, not rel="nofollow";
  • the Verified badge, with no verification step.

Nothing else changes: the listing keeps every free-plan capability and gains none of the Growth or Pro features.

What it costs

PackagePer listing-yearExample
25–99$1525 × $15 = $375
100–499$12100 × $12 = $1,200
500 and up$9500 × $9 = $4,500

The minimum order is 25. The whole order is billed at the price of the band it falls into — there is no mixing of bands. Prices are in US dollars, and the ladder is also served live in every pricing object below, so you never have to hard-code these numbers.

Units belong to the account, not to a company: buy once, spend them across every listing you manage, whenever you like. They do not expire on the shelf — the year starts when you spend one. There are no refunds, and a spent unit cannot be moved to another listing. Paying by invoice, or buying more than 500 at a time? Talk to sales — the same units are booked to your account by hand.

How it works

  • POST /v1/agency/checkout returns a Stripe Checkout URL. Send the buyer there.
  • When Stripe confirms the payment, the units land on the account balance. Nothing is credited before that.
  • POST /v1/listings/{id}/upgrade spends one unit on one listing.
  • Call it again a year later — or any time — to renew: another unit, another year added to the current end date.
  • GET /v1/agency/balance tells you what is left, at any time.

You can also buy from the cabinet at /business/api, where the same balance is shown with a Buy button — useful when the person holding the credit card is not the person holding the key.

GET/v1/agency/balancelistings:read

What the account has left, what it has spent, every package it bought, and the live price ladder. The balance is never a stored number — it is always the sum of units_total - units_used over the packages, so it cannot drift.

upgrades_active counts distinct listings with an unexpired agency year: a renewed listing is one listing, not two.

Request
curl -s https://trustburn.com/api/v1/agency/balance \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "units_available": 23,
  "units_used": 2,
  "packages": [
    {
      "id": 4,
      "units_total": 25,
      "units_used": 2,
      "unit_price": 15,
      "source": "stripe",
      "created_at": "2026-09-08T15:02:11Z"
    }
  ],
  "upgrades_active": 2,
  "pricing": {
    "tiers": [
      { "min": 25, "max": 99, "unit_price": 15 },
      { "min": 100, "max": 499, "unit_price": 12 },
      { "min": 500, "unit_price": 9 }
    ],
    "min_units": 25,
    "currency": "USD",
    "term": "1 year"
  }
}
POST/v1/agency/checkoutlistings:write

Creates a Stripe Checkout Session for units listing-years and answers 201 with its URL. Nothing is charged and nothing is credited here — the balance moves only when Stripe confirms the payment.

The price is decided on our side from the ladder; it is never read from the request. success_url and cancel_url are optional and must be absolute https:// URLs — omitted, the buyer returns to your Trustburn cabinet.

Below 25 units the call answers 422 and includes the ladder, so a client can correct the order without a second request. No idempotency key is needed: an unpaid session costs nothing and expires by itself.

Request
curl -s -X POST https://trustburn.com/api/v1/agency/checkout \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"units": 100}'
Response
{
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_a1b2c3",
  "session_id": "cs_live_a1b2c3",
  "units": 100,
  "unit_price": 12,
  "total": 1200
}
POST/v1/listings/{id}/upgradelistings:write

Spends one unit on one listing: dofollow link and Verified badge for a year. No body.

Calling it again renews — another unit, another year added to the current end date, not to today, so renewing early loses nothing. A listing you do not own answers 404, the same as an unknown id.

A repeat within a few seconds is treated as a double-click: nothing changes, nothing is spent, and the answer carries "charged": false.

With an empty balance the call answers 402 no_agency_credits and hands you the checkout path and the ladder.

Request
curl -s -X POST https://trustburn.com/api/v1/listings/25594885/upgrade \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "listing_id": 25594885,
  "tier": "agency",
  "dofollow": true,
  "verified": true,
  "ends_at": "2027-09-08",
  "units_available": 24,
  "charged": true
}

When the balance is empty

{
  "error": {
    "code": "no_agency_credits",
    "message": "no agency listing-years left on this account; buy a package to continue",
    "checkout": "/v1/agency/checkout",
    "pricing": {
      "tiers": [
        { "min": 25, "max": 99, "unit_price": 15 },
        { "min": 100, "max": 499, "unit_price": 12 },
        { "min": 500, "unit_price": 9 }
      ],
      "min_units": 25,
      "currency": "USD",
      "term": "1 year"
    }
  }
}

Every listing card — GET /v1/listings and GET /v1/listings/{id} — also carries tier, dofollow and agency_ends_at, so you can see which of your listings are covered and which are about to run out without keeping your own ledger.

Review transfers

Move reviews a business already collected on Clutch, Trustpilot, G2, Capterra or Google onto its Trustburn profile — at that business’s request. Two steps: open a transfer and read the preview, then confirm it. Nothing reaches the profile before you confirm, and one call takes it all back.

You can also just give us the link. For platforms whose url_mode is true — today TechBehemoths and Superbcompanies — post {source, source_url, mode:"url"} and we read that public profile page ourselves: our own user agent, one request per second, no headless browser, and only ever a URL whose host belongs to the platform you named. You get 202 and an empty transfer to poll; a minute later it is a normal preview. See Fetching by URL.

Or type them in. Send {source, rows, mode:"manual"} where every row carries its own review_url — the link to that exact review on the source platform. That per-row link is required, becomes each imported review’s badge link, and makes the profile-level source_url optional. It answers 201 with a preview, exactly like sending rows without a mode.

Google has a connector. Not a scraper — the official Business Profile API, over OAuth. The owner authorises it in their cabinet, in a browser, with their own Google account; there is no endpoint here that starts that and there will not be one. Once they have, post {source:"google", mode:"google"} with no source_url and no rows and we read their Google reviews directly. Without it you get 409 google_not_connected carrying a sentence you can show them as it stands. Check connector and configured on the google row of GET /v1/reviews/transfers/sources before you offer the button. See Google.

Transferred reviews count. A transferred review is a full review here: it counts towards the rating and the review counter and sits in the feed under its original date. The one difference is a badge on each one — “Transferred from Clutch at the owner’s request” — linking to the profile you gave as source_url. The company page also carries one honest line: “Includes 12 reviews transferred from Clutch at the owner’s request.” They are deliberately left out of the page’s Review structured data, because Google’s review-snippet policy forbids marking up reviews collected from other sites.

Before you call this. The business must have asked for the transfer, and these must be genuine customer reviews of it. Every transfer is recorded against the key that opened it, and we can withdraw one — or revoke a key.

Limits and rules

source_urlMandatory on every transfer, and it must be the company’s profile on the source platform — not a search page, not your site. It is the badge link, and the only way a reader can check the claim.
Size5,000 rows and 10 MB per transfer.
Frequency3 transfers per listing per rolling 24 hours, shared with the owner’s own cabinet. A transfer left unconfirmed still spends its slot. This does not spend a listings_per_day unit.
Undo30 days from the import, self-service. After that, support.
Rows we dropRating outside 1–5 after normalisation, body under 20 characters, no usable date, a date in the future or over 10 years old, contact details or links in the body, and duplicates — inside the file and against everything previously transferred for this company.
RatingsNormalised to 1–5. 5, 4.0, 4,5, 5/5, 9/10, 8 out of 10 and 4 stars all parse; a 10-point scale is rescaled, so a 9/10 lands as 5 stars rather than an unexplained 9.
DatesThe original review date is kept, so the feed stays in honest order.
FilesCSV (comma, semicolon or tab, with or without BOM) and JSON. No XLSX — save as CSV. Trustpilot, G2 and Capterra exports are recognised by their header row, and so is our own template.

The flow

  1. POST /v1/listings/{id}/reviews/transfers with rows, with a file, or with mode: "url" and nothing else. Rows and files come back 201 with a transfer already in preview; a URL comes back 202 with an empty one and you poll it.
  2. Show that to the owner. GET .../transfers/{tid} pages through the parsed rows if you want the full preview table.
  3. POST .../transfers/{tid}/confirm writes them and returns the recomputed rating.
  4. DELETE .../transfers/{tid} withdraws the whole transfer within 30 days.
GET/v1/reviews/transfers/sourceslistings:read

The closed list of platforms a transfer may name. code is what you send as source; label is the word that appears in the badge sentence. url_mode says whether mode:"url" works for it, and reason is a sentence you can show a user unchanged — why not, or what a fetch will and will not get. Read these rather than hard-coding a list; they change when a platform’s behaviour changes.

connector marks a platform we reach through its own API instead of by reading a page — today only Google — and configured says whether ours is live. connector: true, configured: false is Google before its API access is granted: mode:"google" answers 409 and the CSV template is the way through.

Request
curl -s https://trustburn.com/api/v1/reviews/transfers/sources \
  -H "Authorization: Bearer $TB_KEY"
Response
[
  {
    "code": "techbehemoths",
    "label": "TechBehemoths",
    "url_mode": true,
    "reason": "We read the reviews TechBehemoths publishes on your profile page. Reviews hidden behind their \"View more\" button are only loaded by their JavaScript and cannot be fetched - we will tell you how many we could see.",
    "connector": false,
    "configured": false
  },
  {
    "code": "trustpilot",
    "label": "Trustpilot",
    "url_mode": false,
    "reason": "Trustpilot blocks automated access - download the export from your Trustpilot business account and upload that file instead.",
    "connector": false,
    "configured": false
  },
  {
    "code": "google",
    "label": "Google",
    "url_mode": false,
    "reason": "The Google Business Profile connection is being set up. Copy your Google reviews into our CSV template and upload it for now.",
    "connector": true,
    "configured": false
  }
]
GET/v1/reviews/transfers/template.csvlistings:read

Our CSV template: the canonical header row and two filled example rows. Answers text/csv as an attachment, not JSON. Use it when the export you have is none of the recognised ones, or when the owner would rather not connect Google.

Request
curl -s https://trustburn.com/api/v1/reviews/transfers/template.csv \
  -H "Authorization: Bearer $TB_KEY" \
  -o template.csv
Response
author_name,rating,date,title,text,review_url,source_review_id
"Jane Cooper",5,2025-03-14,"Delivered exactly what they promised","We hired them for a six-month rebuild and they shipped on time.",https://www.example-platform.com/reviews/12345,12345
POST/v1/listings/{id}/reviews/transferslistings:write

Parses, validates and de-duplicates, then answers 201 with a transfer in preview. Writes nothing to the profile. (With mode:"url" it answers 202 with an empty transfer instead — see Fetching by URL.)

Send rows as JSON, or a file as multipart/form-data with the same scalar fields. A body carrying both uses the file. Headers we do not recognise answer 422 unknown_format with the file’s real headers — send them back as a mapping field and the same file parses.

For reviews typed by hand rather than exported, send mode:"manual" with rows that each carry their own review_url; that link is required per row and becomes the review’s badge link, so source_url is optional in this mode.

Rows we will not import are not an error: they come back in rejected_rows with the row number, the reason and an excerpt, so the owner can see exactly which ones.

Request
curl -s -X POST https://trustburn.com/api/v1/listings/25230012/reviews/transfers \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "clutch",
    "source_url": "https://clutch.co/profile/northwind-locks",
    "rows": [
      {
        "author_name": "Jane Cooper",
        "rating": "5",
        "date": "2025-03-14",
        "title": "Delivered exactly what they promised",
        "text": "We hired them for a six-month rebuild and they shipped on time.",
        "source_review_id": "12345"
      }
    ]
  }'
Response
{
  "transfer": {
    "id": 5,
    "status": "preview",
    "channel": "api",
    "source": "clutch",
    "source_label": "Clutch",
    "source_url": "https://clutch.co/profile/northwind-locks",
    "detected_format": null,
    "total": 4,
    "pending": 2,
    "imported": 0,
    "duplicates": 1,
    "rejected": 1,
    "created_at": "2026-09-09T09:25:45Z",
    "rejected_rows": [
      { "row_no": 2, "status": "duplicate", "reason": "duplicate_in_file", "excerpt": "We hired them for a six-month rebuild..." },
      { "row_no": 3, "status": "rejected", "reason": "contact_details_in_text", "excerpt": "Solid delivery overall. Reach them on +1 415..." }
    ]
  }
}
POST/v1/listings/{id}/reviews/transferslistings:write

The same endpoint with a file. This is the shape for a Trustpilot, G2 or Capterra export, or for our template filled in by hand.

The upload ceiling on this path is 10 MB. A body over 12 MB is refused at the edge with the same payload_too_large code before it reaches us.

Request
curl -s -X POST https://trustburn.com/api/v1/listings/25230012/reviews/transfers \
  -H "Authorization: Bearer $TB_KEY" \
  -F "source=trustpilot" \
  -F "source_url=https://www.trustpilot.com/review/northwindlocks.com" \
  -F "[email protected]"
Response
{
  "transfer": {
    "id": 6,
    "status": "preview",
    "source": "trustpilot",
    "source_label": "Trustpilot",
    "detected_format": "trustpilot",
    "file_name": "trustpilot-export.csv",
    "total": 128,
    "pending": 121,
    "duplicates": 5,
    "rejected": 2,
    "rejected_rows": [ ... ]
  }
}
GET/v1/listings/{id}/reviews/transferslistings:read

This listing’s transfer history, newest first, at most 50 — from every channel, so a transfer the owner started in their own cabinet is here too. transfers_today is the daily allowance and what is left of it; transferred_reviews counts what is live on the profile right now.

Request
curl -s https://trustburn.com/api/v1/listings/25230012/reviews/transfers \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "items": [
    {
      "id": 5,
      "status": "done",
      "channel": "api",
      "source": "clutch",
      "source_label": "Clutch",
      "total": 4,
      "pending": 0,
      "imported": 2,
      "duplicates": 1,
      "rejected": 1,
      "imported_at": "2026-09-09T09:25:55Z"
    }
  ],
  "total": 1,
  "transfers_today": { "used": 1, "max": 3 },
  "transferred_reviews": { "count": 2, "sources": { "clutch": 2 } }
}
GET/v1/listings/{id}/reviews/transfers/{tid}listings:read

One transfer plus a page of the rows we parsed, every status — the preview table. limit up to 200, offset to page. review_id on an imported row is the same id GET /v1/listings/{id}/reviews reports.

Request
curl -s "https://trustburn.com/api/v1/listings/25230012/reviews/transfers/5?limit=50" \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "transfer": { "id": 5, "status": "done", "total": 4, "pending": 0, "imported": 2 },
  "items": [
    {
      "id": 4909,
      "row_no": 1,
      "status": "imported",
      "reject_reason": null,
      "author_name": "Jane Cooper",
      "rating": 5,
      "title": "Delivered exactly what they promised",
      "date": "2025-03-14",
      "review_id": "h9J8haABm5K95aQI-vC_"
    },
    {
      "id": 4911,
      "row_no": 3,
      "status": "rejected",
      "reject_reason": "contact_details_in_text",
      "author_name": "Marcus Hall",
      "rating": 4,
      "date": "2024-11-02",
      "review_id": null
    }
  ],
  "limit": 50,
  "offset": 0,
  "total": 4
}
POST/v1/listings/{id}/reviews/transfers/{tid}/confirmlistings:write

Writes every pending row into the live review index and recomputes the rating on the spot. Only a transfer in preview can be confirmed; a second call answers 409 transfer_not_confirmable.

By calling this you state that the business authorised the transfer and that these are genuine customer reviews of it.

This is a synchronous write: on a 5,000-row transfer it takes about half a minute. Give your client at least a 180-second timeout.

Request
curl -s -X POST https://trustburn.com/api/v1/listings/25230012/reviews/transfers/5/confirm \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "transfer": {
    "id": 5,
    "status": "done",
    "source": "clutch",
    "source_label": "Clutch",
    "total": 4,
    "pending": 0,
    "imported": 2,
    "duplicates": 1,
    "rejected": 1,
    "imported_at": "2026-09-09T09:25:55Z",
    "company": { "id": 25230012, "avg_rate": 4.7, "reviews_cnt": 31 }
  }
}
DELETE/v1/listings/{id}/reviews/transfers/{tid}listings:write

Withdraws a completed transfer: every review it wrote leaves the profile, the widgets and the rating, and the counters are recomputed. The transfer itself is not deleted — it moves to undone and stays in the ledger.

Self-service for 30 days after the import. After that, contact support.

Request
curl -s -X DELETE https://trustburn.com/api/v1/listings/25230012/reviews/transfers/5 \
  -H "Authorization: Bearer $TB_KEY"
Response
{
  "transfer": {
    "id": 5,
    "status": "undone",
    "total": 4,
    "imported": 2,
    "removed": 2,
    "undone_at": "2026-09-09T09:29:48Z",
    "company": { "id": 25230012, "avg_rate": 4.6, "reviews_cnt": 29 }
  }
}

Fetching by URL

Send the same POST with mode set to url and no rows and no file:

curl -s -X POST https://trustburn.com/api/v1/listings/25230012/reviews/transfers \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source":"techbehemoths","source_url":"https://techbehemoths.com/company/northwind-locks","mode":"url"}'
HTTP/1.1 202 Accepted
{ "transfer": { "id": 12, "status": "uploaded", "mode": "url", "total": 0, "fetch_note": null, "error": null } }

202, not 201, and the difference is the contract: the transfer exists and is empty. Nothing has been fetched, parsed or validated. Poll GET .../transfers/{tid} — every few seconds is plenty, it normally takes 10–60 seconds — until status leaves uploaded/parsing:

previewDone. items, the counters and rejected_rows are what they would be for an uploaded file — the same validation, the same duplicate rules. Confirm as usual.
failedRead error. It is written for a person: a profile page that does not exist, a platform that refused us, a layout we could not read. A failed fetch does not spend one of the three daily slots.

Read fetch_note and show it. It says what the fetch could not see. A TechBehemoths profile that advertises 18 reviews but only publishes 12 in its HTML — the rest arrive via their own JavaScript — says exactly that, and the owner can add the remainder from the CSV template. Without it, a partial import looks like a bug.

Cap1,000 reviews and 20 pages per fetch (a file may carry 5,000).
source_urlMust be a profile page on the platform named in source. A host that does not match is 422 on source_url — which is also what stops this endpoint from being asked to fetch arbitrary addresses.
AttemptsUp to three, with backoff, and only for failures worth retrying (a timeout, a 5xx). A challenge page, a 403 or a 404 is final on the first try.
DuplicatesA second fetch of the same profile lands as duplicates against the first, not as a second copy.
Not supported422 url_not_supported_for_source, carrying the platform’s own reason and the current url_capable_sources list.

Google, and the platforms we cannot fetch

Google reviews come across three ways: as rows, as a file from our CSV template, or through the Google Business Profile connector — the official API, over OAuth, with the owner’s consent. We have never scraped Google and will not: its review endpoints are closed, reading them breaks its terms, and the old scraper we had is staying switched off.

The owner connects it, not you. OAuth needs a browser and a Google account that administers the Business Profile. They open their listing’s Import reviews page, press Connect, sign in to Google, and pick which of their locations this listing is. Your side of it is one field in one response — the google row of GET /v1/reviews/transfers/sources:

{ "code": "google", "label": "Google", "url_mode": false,
  "connector": true, "configured": false,
  "reason": "The Google Business Profile connection is being set up. Copy your Google reviews into our CSV template and upload it for now." }

connector: true is permanent; configured flips to true the day our own Google API access is live, with no version bump and no change to anything you send. Show reason as it stands rather than writing your own sentence about it.

Once the owner has connected, the call carries nothing but the mode:

curl -s -X POST https://trustburn.com/api/v1/listings/25230012/reviews/transfers \
  -H "Authorization: Bearer $TB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"source":"google","mode":"google"}'
HTTP/1.1 202 Accepted
{ "transfer": { "id": 21, "status": "uploaded", "mode": "google", "source": "google", "total": 0, "error": null } }

No source_url and no rows: the profile link every transferred review has to carry is Google’s own link for the location they picked, read off the connection rather than taken from you — a link we never verified is not one we will publish under a badge. From here it is the mode:"url" flow exactly: poll GET .../transfers/{tid} until status leaves uploaded/parsing, show the preview, confirm. It spends one of the three daily slots and is undoable for 30 days like any other transfer.

409 google_not_connectedNo usable connection on that listing. configured: false in the response means our end is not live yet; true means it is and this owner simply has not connected. Either way the fix is theirs, in a browser — show them message and link them to their Import reviews page.
409 google_no_locationConnected, but they have not chosen which Google location this listing is. One dropdown on the same page.
409 google_no_profile_urlGoogle gave us no public link for that location. Rare; they should reconnect and pick it again.
Cap1,000 reviews per fetch, the same as a URL fetch. Owner replies on Google are not imported — they can answer here instead.

Trustpilot, Clutch and DesignRush answer our reader with a bot challenge. We probed all three from our own address with the user agent above and got a challenge page every time. Getting past one would mean a spoofed browser, a proxy pool or a headless browser, and we do none of those — so for those three the answer is a file, permanently, not “yet”. Trustpilot and G2 both give the business a full export in its own vendor portal, which carries more than the public page shows anyway.

Do not hard-code any of this. GET /v1/reviews/transfers/sources carries url_mode and a reason per platform, and the reason is a sentence you can put in front of a user unchanged. It changes when a platform’s behaviour changes, without an API version bump.

Attribution

Wherever you display Trustburn review data — a rating, a star count, a review body, an aggregate — you must credit Trustburn and link back to that company's Trustburn page.

You do not have to invent the wording. Every company card and every review page hands it to you:

"attribution": {
  "text": "Reviews via Trustburn",
  "url": "https://trustburn.com/reviews/semalt"
}

Render it as a visible link near the data it belongs to:

<a href="https://trustburn.com/reviews/semalt">Reviews via Trustburn</a>

The link must be crawlable and clickable — not an image, not a tooltip, not nofollow-only text with no href.

Terms

  • Your key is yours. Do not share it, embed it in a mobile app, or ship it in front-end JavaScript. Anything done with your key is done as you.
  • No bulk extraction. The API is for serving your own product and your own listings, not for copying the Trustburn corpus. Systematically pulling companies or reviews you have no relationship with — mirroring, dataset building, model training — is not allowed, whatever the request rate.
  • No reselling. Trustburn data may not be sold, sublicensed, or redistributed as a data product.
  • Attribution is required wherever the data is shown. See above.
  • Do not misrepresent. Do not alter review text or ratings, filter them to hide the negative ones from an aggregate you present as complete, or imply Trustburn endorses you.
  • Listings must be real. Create profiles for businesses that exist, with contact details that reach them.
  • We can revoke a key that breaks these rules, and we can change the quotas above. Material changes will be announced in the changelog.

These are the API-specific rules. The full agreement is the Terms of Service; where the two disagree, the Terms of Service win.

Changelog

DateChange
2026-09-09 Google Business Profile connector: mode:"google" on POST /v1/listings/{id}/reviews/transfers reads a listing’s Google reviews through Google’s own API once its owner has connected it in the cabinet (OAuth needs a browser, so there is no API path to that). Answers 202 and polls like mode:"url"; 409 google_not_connected otherwise. GET /v1/reviews/transfers/sources gained connector and configured.
2026-09-09 Review transfers: POST /v1/listings/{id}/reviews/transfers (rows or file), /confirm, /undo, the history and detail reads, plus GET /v1/reviews/transfers/sources and the CSV template. Transferred reviews count towards the rating and carry a badge linking to the source profile; source_url is mandatory, 3 transfers per listing per day, undo within 30 days.
2026-09-08 Agency listing-years: GET /v1/agency/balance, POST /v1/agency/checkout and POST /v1/listings/{id}/upgrade, with the volume ladder and the 402 no_agency_credits error. Listing cards gained tier, dofollow and agency_ends_at; the balance and a Buy button are in the cabinet at /business/api.
2026-09-08 Public company reads: GET /v1/companies/{id}, GET /v1/companies?domain=, POST /v1/companies/lookup, GET /v1/companies/{id}/reviews, GET /v1/companies/{id}/stats. New scopes companies:read and reviews:read, the daily read quota with its X-Daily-Reads-* headers, and ETag/If-None-Match on the cacheable reads. Self-service keys at /business/api, this page, and the OpenAPI 3.1 spec.
2026-09-07 v1 opened: GET /v1/me, the reference data, and the full listing surface — create, read, update, logo, photos, and reviews of your own listings. Scopes listings:read and listings:write, the per-minute limit and the daily listing quota.

Something missing?

If an endpoint you need is not here, or one behaves differently from this page, tell us — contact support and mention the endpoint and the key prefix (never the key itself).