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
} | 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}) |
order_id | string | No | Order reference (auto-generated if omitted) |
fulfillment_id | string | No | Shopify 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_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— Consent-denied (customer lacks Shopify marketing consent) or past-due at creation (trigger event's send window already closed). The row is held for audit only — no email is sent and the token will not authorize a public submission if anyone opens the link.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 Gadget payload includes marketing_state, we enforce Shopify's marketing-consent enum. A value other than "SUBSCRIBED" (including null when a trigger is supplied) returns the row with status: "skipped" instead of "scheduled". If you're calling this endpoint directly without skip_email: true and the upstream payload carries non-consented state, expect "skipped" rather than "scheduled".
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 invalid409 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).
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": 45,
"by_reason": {"suppressed": 22, "manual": 5, "age_guard_14d": 18, "send_failed": 0}
},
"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). suppressions_added_in_windowcounts NEW suppression-list entries created in the window (not currently-active total). It's a "growth" signal.
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