Claude
Skills
Sign in
Back

podium-review-request-automation

Included with Lifetime
$97 forever

Trigger Podium review requests from Shopify order-shipped events and survive the delivery-side failures — cooldown-window violations, ship-event races with refunds, failed-send silent rejections, dropped review-response webhooks, multi-platform routing misconfig, and opt-out compliance gaps. Use when wiring Shopify orders-fulfilled to Podium review campaigns, building a cooldown gate, ingesting review-response webhooks, or auditing opt-out compliance across flows. Trigger with "podium review request", "shopify review automation", "podium cooldown", "review response webhook", "podium opt-out audit", "review platform routing".

Securitypodiumreviewsshopify-integrationcooldown-policyopt-out-complianceautomationscripts

What this skill does


# Podium Review Request Automation

## Overview

Wire Shopify order-shipped events to Podium review requests and operate the delivery layer in production. This is not a campaign-builder walkthrough — it is the integration code your system runs when a merchant ships 300 orders on Black Friday, when a customer initiates a refund 30 minutes after the request fires, when a carrier silently drops the SMS, when a 1-star review-response webhook never arrives, and when an opt-out from a different marketing flow needs to suppress the review-request path.

The six production failures this skill prevents:

1. **Review requests sent during cooldown** — A customer received a review request 5 days ago; a new Shopify shipment triggers another within the cooldown window. The customer is annoyed, replies STOP, and Podium suspends the account for spam-pattern volume. A naive integration has no cooldown gate and rediscovers this rule with every new merchant.
2. **Shopify-ship-event race with refund** — An order ships, the `orders/fulfilled` webhook fires, the review request is sent, and 30 minutes later the customer initiates a refund. The review request is now embarrassing and brand-damaging. Without a delay window between fulfillment and send, every refund-prone product line generates this incident.
3. **Failed-send silent rejection** — Podium accepts the `POST /v4/review-invitations` call and returns 200 with an `invitation_id`. Hours later the actual SMS send is rejected at the carrier (T-Mobile filter, invalid number, opt-out on the destination). The integration thinks it succeeded. The merchant's review velocity quietly drops.
4. **Review-response webhook drops** — A customer leaves a 1-star review. Podium fires the `review.received` webhook. The receiver returns 500 due to a deployment, Podium retries 3 times, then gives up. The team finds out a week later from Google directly. Without webhook persistence + replay + idempotency, low-volume signal evaporates.
5. **Multi-platform routing misconfig** — A campaign is configured to route review requests to Facebook, but the customer doesn't have Facebook. The request quietly fails to land — Podium's response shows "delivered" because the SMS went out, but the destination link 404s on the customer's device. Without per-customer platform validation, the merchant's review pipeline silently caters to the wrong network.
6. **Opt-out compliance gaps** — A customer opted out of marketing SMS 6 months ago, but the opt-out flag sits on the email-marketing flow only. The Podium review-request flow has its own consent path and fires anyway. The merchant catches a TCPA complaint. Opt-out must be a single check across every flow that touches the contact, not per-channel.

## Prerequisites

- Python 3.10+ with `httpx` and `redis` (or `sqlite3` for the SQLite cooldown backend)
- A Podium OAuth app authenticated via the `podium-auth` sibling skill — this skill assumes `auth.get_token()` is available
- A Shopify store with `orders/fulfilled` webhook configured pointing at this integration
- Redis (production) or SQLite (single-node) for cooldown state — keyed by phone number, value is `last_contact_at` epoch seconds
- A persistent inbox for Podium `review.received` webhooks — see the `podium-webhook-reliability` sibling skill for the durable-queue pattern this skill consumes
- An opt-out source-of-truth table that aggregates marketing-SMS, transactional-SMS, and review-flow opt-outs into a single contact-level boolean — see the `podium-contact-dedup` sibling skill for the merge semantics this skill relies on

## Instructions

Build in this order. Each section neutralizes one production failure mode.

### 1. Cooldown gate (neutralizes review-request spam)

Cooldown is a contact-level rate limit, not a campaign-level one. A customer who places two orders in three days must not get two review requests. Key the cooldown by normalized E.164 phone (the canonical contact identifier across Podium and Shopify) and store `last_contact_at` as epoch seconds.

```python
import time
import redis
from typing import Optional

class CooldownGate:
    DEFAULT_COOLDOWN_DAYS = 30

    def __init__(self, redis_url: str, cooldown_days: int = DEFAULT_COOLDOWN_DAYS):
        self.r = redis.from_url(redis_url, decode_responses=True)
        self.cooldown_seconds = cooldown_days * 86400

    def _key(self, phone_e164: str) -> str:
        return f"podium:cooldown:{phone_e164}"

    def can_send(self, phone_e164: str) -> tuple[bool, Optional[float]]:
        """Returns (allowed, seconds_remaining_if_blocked)."""
        last = self.r.get(self._key(phone_e164))
        if last is None:
            return True, None
        elapsed = time.time() - float(last)
        if elapsed >= self.cooldown_seconds:
            return True, None
        return False, self.cooldown_seconds - elapsed

    def mark_sent(self, phone_e164: str) -> None:
        # SETEX with the cooldown window — Redis auto-expires the key, so old contacts roll off.
        self.r.setex(self._key(phone_e164), self.cooldown_seconds, time.time())
```

The cooldown window default is 30 days — adjust per-merchant in `config/settings.yaml`. Critically, `mark_sent` runs **after** Podium accepts the API call, but the cooldown is the gate for the decision — never let two concurrent webhook handlers both pass the check and both send.

### 2. Refund-race buffer (neutralizes premature sends)

Shopify's `orders/fulfilled` fires the moment the merchant marks a shipment as packed and labeled. Customer receipt of the package — and the window for refund decisions before review-worthiness exists — happens hours to days later. Buffer the send by a configurable delay (default 5 days from `fulfilled_at`), and re-check the order's refund status at send time:

```python
async def schedule_review_request(order: dict, send_after: float) -> None:
    """Schedule, do not send-now. The actual send is gated at fire time on refund status."""
    await delayed_queue.enqueue(
        topic="podium.review.send",
        payload={"order_id": order["id"], "phone": order["customer"]["phone"]},
        not_before=send_after,
    )

async def fire_scheduled_send(payload: dict) -> None:
    order = await shopify.get_order(payload["order_id"])
    # Refund check at send time — order may have been refunded in the buffer window
    if order.get("financial_status") in {"refunded", "partially_refunded", "voided"}:
        log_event("review_send_skipped_refund", order_id=order["id"])
        return
    if order.get("cancelled_at") is not None:
        log_event("review_send_skipped_cancelled", order_id=order["id"])
        return
    await send_review_request(order)
```

The delayed queue must survive process restart — Redis streams, SQS with DLQ, or Postgres-backed `pgmq` all work. An in-memory `asyncio.sleep` does not.

### 3. Failed-send detection (neutralizes silent rejection)

The Podium API's `POST /v4/review-invitations` response only confirms the invitation record was created — it does not confirm the SMS was delivered to the carrier. Subscribe to `review_invitation.failed` and `review_invitation.delivered` webhooks separately, persist them keyed by `invitation_id`, and reconcile delivery state against the original request after a 24-hour SLA window:

```python
class InvitationOutbox:
    """Tracks every send → carrier-confirmed-delivered transition. Anything unresolved after 24h is escalated."""

    def record_sent(self, invitation_id: str, phone: str, order_id: str) -> None:
        self.r.hset(f"podium:inv:{invitation_id}", mapping={
            "status": "sent",
            "phone": phone,
            "order_id": order_id,
            "sent_at": time.time(),
        })
        self.r.expire(f"podium:inv:{invitation_id}", 86400 * 7)

    def record_delivered(self, invitation_id: str) -> None:
        self.r.hset(f"podium:inv:{invitation_id}", "status", "delivered")
        self.r.hset(f"podium:inv:{invitation_id}", "delivered

Related in Security