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": "customer@example.com",
  "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})
order_idstringNoOrder reference (auto-generated if omitted)
fulfillment_idstringNoShopify fulfillment ID. Populated on requests created by the fulfilled or delivered trigger (one request per physical shipment). 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
  • 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).

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.

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

Four additional merchant-facing labels are reserved for future cancel causes (Trigger changed — order no longer matched, Delay changed — order rescheduled, Customer already reviewed, Duplicate request — already sent) — 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 fifth (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