# Start a flow from your system

Source: https://docs.revoplyai.com/guides/start-a-flow-from-your-system/

> Send a WhatsApp template when an order ships, by posting your order JSON to a flow trigger URL, with safe retries.

This guide sends a customer a WhatsApp message when your store marks their order as
shipped, and lets them reply in the same conversation. You need the Business plan, a
connected WhatsApp Business number, an approved template, and an Owner or Admin login.

## 1. Build the flow [#1-build-the-flow]

1. In **Automations**, create a flow and choose the trigger **Your system sends an event**.
2. Choose the WhatsApp number to send from.
3. Add a template step first, such as your approved `order_shipped` template. A flow
   started by your system must send a template before anything else. Add whatever follows:
   a question, a handover to your team, an end.
4. **Create the URL** and copy it into your store's server configuration as
   `REVOPLY_HOOK_URL`. It is shown in full only once. Treat it as a password.

Leave the flow unpublished for now.

## 2. Send a sample request [#2-send-a-sample-request]

Send one request with your own phone number in it, in the shape your store already uses:

```bash
curl --silent --show-error "$REVOPLY_HOOK_URL" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: order-10482-shipped' \
  --data '{
    "order": { "id": "10482", "status": "shipped" },
    "customer": { "name": "نورة العتيبي", "phone": "+966501234567" }
  }' \
  --write-out '\nHTTP %{http_code}\n'
```

It is answered `409` with `"status": "flow_off"`, because the flow is not published, and
the builder keeps the body. Back in the trigger settings, choose **Refresh**, then tap
`customer.phone` for the phone number, `customer.name` for the name, and `order.id` as a
value to keep, say as `order_id`, to use in the template.

## 3. Publish and send for real [#3-publish-and-send-for-real]

Publish the flow and switch it on. Now call the URL from your code when an order ships.
Use an `Idempotency-Key` built from the event itself, such as `order-10482-shipped`, and
send the same key on every retry of that event:

<CodeBlockTabs defaultValue="Node.js">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Node.js">
      Node.js
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Node.js">
    ```js
    // Starts a RevoplyAI flow from your system (Node.js 18+, no packages).
    //
    //   REVOPLY_HOOK_URL=https://api.revoplyai.com/hooks/rvh_… node start-flow.node.mjs
    //
    // The hook URL is the credential: keep it in your server's configuration, never in a browser.

    import { fileURLToPath } from 'node:url';

    // #region start
    /** The outcomes worth trying again later with the same Idempotency-Key. */
    const RETRYABLE = new Set([
      'channel_unavailable',
      'rate_limited',
      'template_limit_reached',
      'customer_limit_reached',
      'service_unavailable',
    ]);

    export async function startFlow(hookUrl, payload, idempotencyKey, { fetchImpl = fetch } = {}) {
      const response = await fetchImpl(hookUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
        body: JSON.stringify(payload),
      });

      // Every answer is { id, status, detail }, except a 429 from the per-address limit in front
      // of the hooks, which is plain text.
      const outcome = await response.json().catch(() => ({ id: null, status: null, detail: null }));
      const status = outcome.status ?? (response.status === 429 ? 'rate_limited' : 'unexpected');

      return {
        ok: status === 'accepted' || status === 'duplicate',
        retry: RETRYABLE.has(status) || response.status >= 500,
        httpStatus: response.status,
        runId: outcome.id,
        status,
        detail: outcome.detail,
      };
    }
    // #endregion

    // #region example
    const order = {
      order: { id: '10482', status: 'shipped' },
      customer: { name: 'نورة العتيبي', phone: '+966501234567' },
    };

    async function main() {
      // One key per business event, reused on every retry of it: a retry that reaches us twice
      // starts the flow once.
      const key = `order-${order.order.id}-${order.order.status}`;

      for (let attempt = 1; attempt <= 4; attempt++) {
        const result = await startFlow(process.env.REVOPLY_HOOK_URL, order, key);
        console.log(
          `${result.httpStatus} ${result.status}${result.detail ? `: ${result.detail}` : ''}`,
        );
        if (result.ok) {
          console.log(`run ${result.runId}`);
          return 0;
        }
        if (!result.retry) return 1;
        await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 15_000));
      }
      return 1;
    }
    // #endregion

    if (process.argv[1] === fileURLToPath(import.meta.url)) {
      process.exitCode = await main();
    }

    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python
    """Starts a RevoplyAI flow from your system (Python 3.9+, standard library).

        REVOPLY_HOOK_URL=https://api.revoplyai.com/hooks/rvh_... python start_flow.py

    The hook URL is the credential: keep it in your server's configuration, never in a browser.
    """

    import json
    import os
    import sys
    import time
    import urllib.error
    import urllib.request

    # region start
    # The outcomes worth trying again later with the same Idempotency-Key.
    RETRYABLE = {
        "channel_unavailable",
        "rate_limited",
        "template_limit_reached",
        "customer_limit_reached",
        "service_unavailable",
    }


    def start_flow(hook_url: str, payload: dict, idempotency_key: str) -> dict:
        request = urllib.request.Request(
            hook_url,
            data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
            headers={"Content-Type": "application/json", "Idempotency-Key": idempotency_key},
            method="POST",
        )
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                http_status, body = response.status, response.read()
        except urllib.error.HTTPError as error:  # 4xx and 5xx still carry { id, status, detail }
            http_status, body = error.code, error.read()

        try:
            outcome = json.loads(body)
        except ValueError:  # the per-address limit in front of the hooks answers 429 as plain text
            outcome = {"id": None, "status": "rate_limited" if http_status == 429 else "unexpected", "detail": None}

        status = outcome.get("status")
        return {
            "ok": status in ("accepted", "duplicate"),
            "retry": status in RETRYABLE or http_status >= 500,
            "http_status": http_status,
            "run_id": outcome.get("id"),
            "status": status,
            "detail": outcome.get("detail"),
        }
    # endregion


    # region example
    def main() -> int:
        order = {
            "order": {"id": "10482", "status": "shipped"},
            "customer": {"name": "نورة العتيبي", "phone": "+966501234567"},
        }
        # One key per business event, reused on every retry of it.
        key = f"order-{order['order']['id']}-{order['order']['status']}"

        for attempt in range(1, 5):
            result = start_flow(os.environ["REVOPLY_HOOK_URL"], order, key)
            print(result["http_status"], result["status"], result["detail"] or "")
            if result["ok"]:
                print("run", result["run_id"])
                return 0
            if not result["retry"]:
                return 1
            time.sleep(2**attempt * 15)
        return 1
    # endregion


    if __name__ == "__main__":
        sys.exit(main())

    ```
  </CodeBlockTab>
</CodeBlockTabs>

A `202` with `"status": "accepted"` means the run is queued: the template goes out in a
moment. A `200` with `"status": "duplicate"` means this order's event already started a
run; nothing new happens.

## 4. Handle what comes back [#4-handle-what-comes-back]

* Retry `rate_limited`, `template_limit_reached`, `customer_limit_reached`,
  `channel_unavailable` and `service_unavailable` later, with the same key. The samples
  above back off and try up to four times.
* Fix, do not retry, everything else. `invalid_phone` means the phone path found nothing
  readable; send numbers with their country code.
* Never retry `opted_out`.

The full list is in [Responses and errors](/flow-triggers/responses-and-errors/).

## 5. Learn how each run ended [#5-learn-how-each-run-ended]

Keep the `id` from the `202`. Subscribe a [webhook](/webhooks/) endpoint to
**Automation completed** and **Automation failed**: their `data.run.id` is that id, and
`data.run.variables` holds what the flow collected, such as the customer's answer.

## Before going live [#before-going-live]

* Test with your own number; there is no test mode.
* Each accepted request sends a template Meta bills for, and counts against the daily
  allowance for automated templates and the limit of 3 per customer in 24 hours.
* If the URL may have leaked, **Replace the URL**: the old one stops working at once.
