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
} | Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Customer email (encrypted at rest) |
customer_name | string | No | Customer name (encrypted at rest) |
product_ids | string[] | No | Shopify product IDs (format: shopify-{id}). A Shopify GID (gid://shopify/Product/{id}) is accepted and stored as the bare {id}. |
order_id | string | No | Order 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_id | string | No | Shopify 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_days | integer | No | Days before email is sent (default: 7). Use 0 for immediate. |
skip_email | boolean | No | If 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 afterdelay_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 toscheduled.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 toscheduled.sent— Email accepted by the provider. Theresend_message_idfield is populated.manual— Created withskip_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. Seecancelled_reasonfor 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 hasreview_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 thedeliveredtrigger 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 ownfulfillment_id). Promotes toscheduledwhen the delivered event arrives, OR when thereview_request_delivery_fallback_daystimer 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"]returnsstatus: "skipped"regardless of the merchant flag. - Default (transactional mode):
require_marketing_consent: false— any state outside the hard-skip set producesstatus: "scheduled". New stores get this default. - Strict mode:
require_marketing_consent: true— only literal"SUBSCRIBED"passes; anything else (includingnullwhen a trigger is supplied) returnsstatus: "skipped". Legacy stores were backfilled totrueat 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 postsevent_type: "delivered"when the carrier reports delivery), OR - The
DeliveryFallbackWorkercron (runs every 30 minutes) whendelivery_wait_untilhas 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 invalid422 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. Forfulfilledanddeliveredtriggers, dedup is keyed on (store,fulfillment_id) — one request per physical shipment. Forcreatedandpaidtriggers, 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 withskip_email: trueare 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
| Param | Type | Description |
|---|---|---|
from | ISO8601 date | Window start. Defaults to 30 days ago. Applied to sent_at for delivery aggregates and to cancelled_at for cancelled aggregates. |
to | ISO8601 date | Window end (inclusive). Defaults to today. |
trigger_type | enum | created | 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
*_pctvalues are clamped to[0.0, 100.0]andnullwhen the denominator (sent) is zero.started_chat/submitted_reviewcount each review request at most once — two conversations on one request count as 1.by_triggeralways emits all four trigger keys with zero objects when no rows match.- Cancelled aggregate window pivots on
cancelled_at, notsent_at(cancelled rows havesent_at = null). by_reason_annotatedcarries the merchant-facing label for each reason, sorted by descending count. Entries withlabel: nullare internal reasons not surfaced in merchant UI — filter or bucket them. The full taxonomy is reproduced below.suppressions_added_in_windowcounts 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 atom | Label |
|---|---|
age_guard_14d | Order too old to send |
paused | Collection paused |
collection_disabled | Collection paused |
bounced | Email bounced |
complained | Customer complained |
unsubscribed | Customer unsubscribed |
suppressed | Customer can't receive emails (umbrella fallback for pre-2026-05-22 rows; new rows write the specific atom) |
consent_gate_tightened | Marketing 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_detected | Email 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_requested | Duplicate 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 atom | Meaning |
|---|---|
ops_rollback | Admin paused a warmup mid-flight |
missing_identity | Scheduler regression — corrupted job args (pages ops) |
no_warmup_state | Fail-closed default for unprovisioned merchants |
manual | Direct 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_failed | Terminal email-provider error after retry exhaustion |
gid_duplicate | One-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_idsarray - Customer reviews each product via AI chat or simple form
- Expired links show a "Link Expired" page with option to request a new one
Book a demo