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 — 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 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 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 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": 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

  • *_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).
  • suppressions_added_in_window counts 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_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