Review Requests API

Last updated: April 26, 2026

Review requests generate unique hub URLs that you share with customers. Each link takes the customer to a page where they can review their purchased products via AI-guided chat.

Create Review Request

POST /api/v1/review-requests/send

Creates a review request and returns a hub URL.

Request

{
  "email": "[email protected]",
  "customer_name": "Jane Doe",
  "product_ids": ["shopify-7125386199075", "shopify-7125386199076"],
  "order_id": "ORD-1234",
  "fulfillment_id": "shopify-F-123",
  "delay_days": 0,
  "skip_email": true
}
FieldTypeRequiredDescription
emailstringYesCustomer email (encrypted at rest)
customer_namestringNoCustomer name (encrypted at rest)
product_idsstring[]NoShopify product IDs (format: shopify-{id}). A Shopify GID (gid://shopify/Product/{id}) is accepted and stored as the bare {id}.
order_idstringNoOrder reference (auto-generated if omitted). A Shopify GID (gid://shopify/Order/{id}) is accepted and stored as the bare {id}, so the same order sent in either form is recognised as a duplicate.
fulfillment_idstringNoShopify fulfillment ID. Populated on requests created by the fulfilled or delivered trigger (one request per physical shipment; the customer is still emailed at most once per order — a later shipment is skipped with no request created, or, if its request was already queued, cancelled order_already_requested). Null for order-level triggers (created/paid) and admin manual sends.
delay_daysintegerNoDays before email is sent (default: 7). Use 0 for immediate.
skip_emailbooleanNoIf true, no email is sent. You get the hub_url to share manually.

Response (201)

{
  "ok": true,
  "request_id": 456,
  "status": "manual",
  "scheduled_at": "2026-03-27T14:00:00Z",
  "hub_url": "https://api.betterreviews.app/review/hub?token=..."
}

Status values:

  • pending — Default state for freshly created rows before scheduling resolves. You'll rarely see this in responses.
  • scheduled — Email will be sent automatically after delay_days.
  • claimed — Transient: scheduler has selected the row and is inserting it into the email queue. Typically lasts a few hundred milliseconds; never longer than 5 min before the system reverts to scheduled.
  • enqueued — Transient: email job is in the queue, awaiting confirmation from the email provider. Typically lasts under a second; never longer than 10 min before the system reverts to scheduled.
  • sent — Email accepted by the provider. The resend_message_id field is populated.
  • manual — Created with skip_email: true. Share the hub_url yourself.
  • cancelled — Cancelled by the merchant, by an unsubscribe/bounce webhook, by the system, or by a permanent send failure. See cancelled_reason for attribution.
  • skipped — Held for audit only; no email is sent and the token will not authorize a public submission. Three causes: (1) past-due at creation — the trigger event's send window has already closed; (2) explicit-no Shopify consent state — marketingState in ["UNSUBSCRIBED", "REDACTED", "INVALID"] is hard-skipped regardless of merchant settings; (3) strict-mode merchant — when the store has review_settings.require_marketing_consent: true, any state other than "SUBSCRIBED" is also skipped. See the Marketing consent user guide for the full model.
  • awaiting_delivery — Placeholder row for the delivered trigger while BetterReviews waits for Shopify's carrier-delivery confirmation. One row is created per physical fulfillment (split shipments produce multiple awaiting rows, each keyed by its own fulfillment_id). Promotes to scheduled when the delivered event arrives, OR when the review_request_delivery_fallback_days timer expires. The token does not authorize public submission until promotion.

Signals we respect when scheduling

When the upstream payload includes marketing_state, the consent gate is evaluated per the per-store require_marketing_consent setting:

  • Explicit-no states are always hard-skipped: marketing_state in ["UNSUBSCRIBED", "REDACTED", "INVALID"] returns status: "skipped" regardless of the merchant flag.
  • Default (transactional mode): require_marketing_consent: false — any state outside the hard-skip set produces status: "scheduled". New stores get this default.
  • Strict mode: require_marketing_consent: true — only literal "SUBSCRIBED" passes; anything else (including null when a trigger is supplied) returns status: "skipped". Legacy stores were backfilled to true at the consent-model migration deploy.

The decision-time consent value is captured in review_requests.marketing_state_at_creation for regulator forensics (surfaced in GDPR Article 15 exports). See the Marketing consent user guide for the full model.

Delivered trigger — awaiting_delivery lifecycle

When a store has review_request_trigger = "delivered", every physical fulfillment first creates an awaiting_delivery row (not immediately scheduled), keyed by fulfillment_id. The row is promoted to scheduled by either:

  • A delivered event from Shopify for the matching fulfillment_id (Gadget posts event_type: "delivered" when the carrier reports delivery), OR
  • The DeliveryFallbackWorker cron (runs every 30 minutes) when delivery_wait_until has passed.

Tokens rotate on promotion, so a token leaked while the row was awaiting_delivery cannot be used after promotion.

Errors

  • 422 invalid_email — Email format is invalid
  • 422 not_sent — The request was refused (e.g. the customer has unsubscribed from this store's emails). The body is always exactly {"error": "not_sent"}.
  • 409 duplicate_request — Active request already exists. For fulfilled and delivered triggers, dedup is keyed on (store, fulfillment_id) — one request per physical shipment. For created and paid triggers, dedup is keyed on (store, order_id, email).
  • 409 collection_disabled — The store is not accepting reviews: review collection has not been switched on (or the app is uninstalled or frozen). Turn review collection on in the dashboard, then retry. This is never a billing error — it applies on every plan, Free included.
  • 429 rate_limited — Too many manual review requests. The limit is 600 sends per store per hour; calls with skip_email: true are metered separately on a much looser limit. It is a flood brake for bulk backfills, not a plan quota.

List Review Requests

GET /api/v1/review-requests

Returns the 50 most recent review requests.

Response (200)

{
  "requests": [
    {
      "id": 456,
      "order_id": "ORD-1234",
      "fulfillment_id": "shopify-F-123",
      "product_ids": ["shopify-7125386199075"],
      "status": "manual",
      "sent_at": null,
      "inserted_at": "2026-03-27T14:00:00Z"
    }
  ]
}

Cancel Review Request

DELETE /api/v1/review-requests/:id

Cancels a review request. The hub URL stops working.

Response (200)

{"ok": true}

Email Funnel Stats

GET /api/v1/email-requests/stats

Aggregate counts and rates for the outbound email review-request funnel: sent, opened, clicked, bounced, complained, plus per-trigger breakdown, cancellation reasons, suppressions, and conversion (sent → started chat → submitted review). Read-only — non-GET verbs return 405 with Allow: GET. Aggregate-only — no per-row data.

Query parameters

ParamTypeDescription
fromISO8601 dateWindow start. Defaults to 30 days ago. Applied to sent_at for delivery aggregates and to cancelled_at for cancelled aggregates.
toISO8601 dateWindow end (inclusive). Defaults to today.
trigger_typeenumcreated | paid | fulfilled | delivered. Invalid values silently dropped.

Window cap: (to − from) > 90 days returns 422 window_too_large. from > to returns 422 invalid_window.

Example

curl "https://api.betterreviews.app/api/v1/email-requests/stats?from=2026-04-01&trigger_type=delivered" \
  -H "X-API-Key: YOUR_API_KEY"

Response (200)

{
  "store_id": 123,
  "window": {"from": "2026-04-01", "to": "2026-04-26", "days": 25},
  "filters_applied": {"trigger_type": "delivered"},
  "delivery": {
    "sent": 1234,
    "opened": 600, "open_rate_pct": 48.62,
    "clicked": 320, "click_rate_pct": 25.93,
    "bounced": 12, "bounce_rate_pct": 0.97,
    "complained": 1, "complaint_rate_pct": 0.08
  },
  "cancelled": {
    "total": 49,
    "by_reason": {"suppressed": 22, "age_guard_14d": 18, "manual": 5, "consent_gate_tightened": 3, "promotional_content_detected": 1},
    "by_reason_annotated": [
      {"reason": "suppressed", "label": "Customer can't receive emails", "count": 22},
      {"reason": "age_guard_14d", "label": "Order too old to send", "count": 18},
      {"reason": "manual", "label": null, "count": 5},
      {"reason": "consent_gate_tightened", "label": "Marketing consent now required", "count": 3},
      {"reason": "promotional_content_detected", "label": "Email template contained promotional content", "count": 1}
    ]
  },
  "suppressions_added_in_window": 22,
  "by_trigger": {
    "created":   {"sent": 100, "opened": 40, "clicked": 18, "bounced": 1, "complained": 0},
    "paid":      {"sent": 200, "opened": 80, "clicked": 40, "bounced": 2, "complained": 0},
    "fulfilled": {"sent": 500, "opened": 240, "clicked": 130, "bounced": 5, "complained": 1},
    "delivered": {"sent": 434, "opened": 240, "clicked": 132, "bounced": 4, "complained": 0}
  },
  "conversion": {
    "sent": 1234,
    "started_chat": 234, "started_chat_pct": 18.96,
    "submitted_review": 78, "submitted_review_pct": 6.32
  }
}

Notes

  • *_pct values are clamped to [0.0, 100.0] and null when the denominator (sent) is zero.
  • started_chat / submitted_review count each review request at most once — two conversations on one request count as 1.
  • by_trigger always emits all four trigger keys with zero objects when no rows match.
  • Cancelled aggregate window pivots on cancelled_at, not sent_at (cancelled rows have sent_at = null).
  • by_reason_annotated carries the merchant-facing label for each reason, sorted by descending count. Entries with label: null are internal reasons not surfaced in merchant UI — filter or bucket them. The full taxonomy is reproduced below.
  • suppressions_added_in_window counts NEW suppression-list entries created in the window (not currently-active total). It's a "growth" signal.

Cancel-reason taxonomy

by_reason keys come from a fixed enum on review_requests.cancelled_reason. The annotated form maps each to either a merchant-facing label or null (internal).

Merchant-facing reasons (label != null)

reason atomLabel
age_guard_14dOrder too old to send
pausedCollection paused
collection_disabledCollection paused
bouncedEmail bounced
complainedCustomer complained
unsubscribedCustomer unsubscribed
suppressedCustomer can't receive emails (umbrella fallback for pre-2026-05-22 rows; new rows write the specific atom)
consent_gate_tightenedMarketing consent now required — fires when a merchant flips require_marketing_consent: false → true and in-flight non-SUBSCRIBED rows are cancelled. Added 2026-06-03.
promotional_content_detectedEmail template contained promotional content — fires when the runtime pre-send scan trips on promotional phrases (discounts, percent-off, coupons, etc.) in your email template. The row is cancelled instead of sent; the merchant template needs scrubbing. Added 2026-06-03.
order_already_requestedDuplicate request — already sent (this customer was already emailed for this order, so the already-queued request for a later shipment — e.g. the second box of a split order — was cancelled instead of sent; a shipment that arrives after the email went out never creates a request and so never appears here). Added 2026-10-07.

Internal-only reasons (label: null, do not surface in merchant UI)

reason atomMeaning
ops_rollbackAdmin paused a warmup mid-flight
missing_identityScheduler regression — corrupted job args (pages ops)
no_warmup_stateFail-closed default for unprovisioned merchants
manualDirect operator action (support cancellation) OR merchant-initiated trigger change via the Collect dashboard (PR4b cascade). A dedicated trigger_changed enum value is reserved for a future split.
send_failedTerminal email-provider error after retry exhaustion
gid_duplicateOne-off data repair cancelled the unsent one of two requests for the same order and customer (one stored with Shopify GID ids, the other with bare ids), so the customer is not emailed twice

Three additional merchant-facing labels are reserved for future cancel causes (Trigger changed — order no longer matched, Delay changed — order rescheduled, Customer already reviewed) — they don't currently appear in by_reason but the canonical list is exposed via PPO.Reviews.CancelReason.all_labels/0 so admin UI legends can ship the full set. The fourth (Sending failed — we'll retry) is reserved as an alias for transient send_failed retry attempts; today's send_failed is the terminal-after-retry-exhaustion case and stays internal-only.


Hub URL Behavior

  • Each hub URL is unique per customer per request
  • Valid for 90 days after creation
  • Shows all products from the product_ids array
  • Customer reviews each product via AI chat or simple form
  • Expired links show a "Link Expired" page with option to request a new one