--- title: Webhook Integration Guide --- This is the backend companion to the **Frontend Embedding Guide**. The frontend Dropin handles UI and discount selection; this webhook is what **finalizes** the SimplyClub transaction after the customer pays. Without it, points are never awarded and benefits are never locked. > **Shopify?** The official **SimplyClub Shopify app** sends this webhook automatically. This guide is for WordPress/WooCommerce, custom storefronts, and any other host backend. --- ## 1. What this webhook does When your customer completes payment on the host: 1. Your **backend** POSTs the order to SimplyClub's order-webhook. 2. SimplyClub resolves the open session via `sc_transaction_code`, looks up the member, and closes the transaction (`TranEnd`). 3. Points are awarded / deducted, discounts locked, and a response is returned with `customer.clubId`, `customer.cardNumber`, plus a list of `updates` (tags + metafields) you should write back to your order for admin visibility. If you skip this step, the user sees the discount applied at checkout but no points are awarded, the benefits are not consumed on the SimplyClub side, and the cart-session times out without a record. --- ## 2. Endpoint ``` POST {BASE_URL}/api/v1/transaction/order-webhook Content-Type: application/json ``` | Environment | `BASE_URL` | |---|---| | Production | `https://dropins.simplyclub.co.il` | | Staging | `https://simply-drop-ins-2.onrender.com` | **No auth header** — the request is authenticated by `sc_transaction_code` (an unguessable, single-session token issued during checkout). Always send over HTTPS. --- ## 3. When to send | Host event | Send `topic` | Default state | |---|---|---| | Order paid / completed | `orders/paid` | ✅ Always on | | Order refunded (full or partial) | `refunds/create` | ❌ **Off by default** — request SimplyClub to enable for your POI | These are the **only two** values the handler acts on. Any other value (including `orders/cancelled` or `orders/refunded`, which look natural but are NOT handled) falls through the per-POI topic toggle, which defaults to `false` for unknown topics — so the endpoint returns **HTTP 200 with `{ skipped: true }`**, not an error. The current production WP plugin sends `orders/cancelled` / `orders/refunded` for those WC lifecycle events — those calls are silently skipped (no-op), so cancel/refund flows from WP today only work for the `orders/paid` path. Treat `refunds/create` as the canonical refund topic. --- ## 4. Required fields Every request body **must** include: | Field | Type | Where it comes from | |---|---|---| | `topic` | `"orders/paid"` \| `"refunds/create"` | You set this based on the host event. | | `domain` | string | The host store's domain (used in logs + admin links). | | `sc_transaction_code` | string | The `transactionCode` your frontend captured via `On.transactionCodeCreated(...)` and persisted as order metadata. | | `order_id` | string \| number | The native host order ID. | | `customer_id` | string \| number | The native host customer ID. **Required for `orders/paid`.** | Strongly recommended: | Field | Type | Why | |---|---|---| | `order_number` | string \| number | Surfaces in the SimplyClub admin and audit logs. | | `total_price` | string | Persisted on the session — used for refund reconciliation. | | `items` *or* `line_items` | array | Required for `orders/paid` — line items the SimplyClub backend uses to build `TranItems`. | | `discount_applications` | array | Lets SimplyClub distinguish its own discount (`הנחת מועדון`) from host promo codes when building the audit trail. | Line item shape (Shopify-compatible — most hosts can pass through with minimal mapping): ```ts { id: number | string, sku: string, variant_id?: number | string, title: string, price: number, // unit price (net of host discounts) quantity: number, discount_allocations?: Array<{ amount: string, discount_application_index: number }>, extended_attributes?: { tags?: any[], collections?: any[], metafields?: any[] } } ``` --- ## 5. Example — `orders/paid` ```bash curl -X POST https://dropins.simplyclub.co.il/api/v1/transaction/order-webhook \ -H "Content-Type: application/json" \ -d '{ "topic": "orders/paid", "domain": "demo-store.example.com", "sc_transaction_code": "7770928", "order_id": "6647180362050", "customer_id": "7602153062442", "order_number": "1042", "total_price": "139.90", "items": [ { "id": 44550337626248, "sku": "1004", "title": "Heart Fitted Crop Top", "variant_id": 44550337626248, "price": 39.9, "quantity": 2, "discount_allocations": [ { "amount": "10.00", "discount_application_index": 0 } ] } ], "discount_applications": [ { "title": "הנחת מועדון", "value": "10.00", "value_type": "fixed_amount" } ] }' ``` --- ## 6. Reading the response All responses use the same envelope: ```ts { success: boolean, data: { ... }, error: any | null } ``` The HTTP status alone is **not enough** — you must inspect `success`: | HTTP | `success` | Meaning | What to do | |---|---|---|---| | 200 | `true` | Closed successfully. `data` includes `customer.clubId`, `customer.cardNumber`, `transactionResponse`, and `updates` (tags + metafields). | Apply `updates` to your order so admin sees the SimplyClub status. Mark the order as Club-synced. | | 200 | `true` + `data.skipped` | Topic toggle is off for this POI. | No-op — but note that you tried. | | 200 | `false` | Reached SimplyClub but the transaction failed (`errorCode`, `errorMessage`, `processName` in `data`). `updates` contains an `sc_error_message` metafield + `sc:paid_error 🔴` tag. | **Do not retry** — log the error and surface it in admin. Investigate before resubmitting. | | 400 | `false` | Missing required field, or POI not found for the resolved session. | Fix the request — never retry without changes. | | 500 | `false` | Server error — including a `sc_transaction_code` that resolves to **no session** (`Session not found` is thrown and surfaces as 500), or any other uncaught error. | If caused by a transient error, safe to retry with the same payload. A persistent 500 from an unknown transaction code means the code is wrong — fix it, don't retry. | ### Successful response — example shape ```json { "success": true, "data": { "transactionResponse": { "ErrorCode": 0, "ErrorMessage": "" }, "sessionId": "65f...", "transactionCode": "7770928", "customer": { "clubId": "200", "cardNumber": "2886100108" }, "updates": { "order": { "tags": { "add": ["sc:paid 🟢"], "remove": [] }, "metafields": { "set": [ { "namespace": "sc_custom", "key": "sc_info", "value": "...", "type": "multi_line_text_field" } ] } }, "customer": { "tags": { "add": [], "remove": [] } } } }, "error": null } ``` ### Apply `updates` back to the order The `updates` object is **nested** under `order` and `customer`, and tells you what to write to the host order for admin/operator visibility: * `updates.order.tags.add` / `updates.order.tags.remove` — order tags (e.g. `sc:paid 🟢`, `sc:paid_error 🔴`, `sc:refunded 🟢`). * `updates.order.metafields.set` — metafields under the `sc_custom` namespace (`sc_info`, `sc_error_message`). * `updates.customer.tags.add` / `updates.customer.tags.remove` — customer-level tags. How you apply these is host-specific (Shopify Admin API, WooCommerce order meta, etc.). What matters is that the SimplyClub status is visible in the admin so support can debug failed syncs. --- ## 7. Idempotency, retries, and edge cases * **No early-return idempotency guard.** If the session is already in a terminal state (`paid` / `refunded` / `cancelled` / `expired` / `closed`), the webhook only **logs a warning and continues processing** — it does *not* short-circuit or skip the SimplyClub call. Avoid re-delivering the same `orders/paid` payload, since a re-send will reprocess the transaction. * **One transaction code per order.** Never reuse `sc_transaction_code` across orders. Capture a fresh one per checkout via `On.transactionCodeCreated`. * **Business errors return 200.** Don't auto-retry on `success: false` with a 200 — the call reached SimplyClub and was rejected for a reason (member not found, validation, etc.). Auto-retry **only** on 500. * **Discount titles matter.** SimplyClub identifies its own discount by matching the title `הנחת מועדון` within `discount_applications[].title` (substring match). Use the host-side discount label you applied on the frontend (per the §5 instructions in the Technical Doc — `sc_club_discount` is the ID; the displayed title must remain `הנחת מועדון`). * **No auth header is required**, but you should still send from a trusted backend (not the browser) so the `sc_transaction_code` is never exposed. --- ## 8. Checklist before going live - [ ] Backend POSTs to the correct `BASE_URL` (prod vs staging). - [ ] `sc_transaction_code` is captured at checkout from `On.transactionCodeCreated` and persisted on the order. - [ ] All five required fields are present in every request (`topic`, `domain`, `sc_transaction_code`, `order_id`, `customer_id`). - [ ] Response handling inspects `success` (not just HTTP 200). - [ ] `updates.order.tags.add` / `updates.order.metafields.set` are applied back to the order so admin can see Club status. - [ ] On HTTP 500 you retry with backoff; on `success: false` you log and stop. - [ ] If you need refund processing, you've asked SimplyClub to enable `refunds/create` for your POI. --- ## 9. WordPress / WooCommerce recipe The production **wp-dropins** plugin (`Simply Club DropIns for WooCommerce`) implements every pattern below — copy it directly or take the snippets that fit. ### 9.1 Hook into WC lifecycle events, dispatch async WooCommerce can fire `woocommerce_payment_complete` from inside the customer's checkout request. Doing the SimplyClub POST inline blocks that response for the duration of the round-trip. Schedule it via **Action Scheduler** instead so the HTTP call runs in the background: ```php // Trigger on payment + status transitions to handle every gateway path. add_action('woocommerce_payment_complete', [$this, 'queue_paid'], 10, 1); add_action('woocommerce_order_status_processing', [$this, 'queue_paid'], 10, 2); add_action('woocommerce_order_status_completed', [$this, 'queue_paid'], 10, 2); // Action Scheduler executes process_webhook_in_background out-of-band. add_action('simply_send_webhook_action', [$this, 'process_webhook_in_background'], 10, 2); public function queue_paid($order_id) { as_enqueue_async_action('simply_send_webhook_action', [$order_id, 'order_paid']); } ``` Inside `process_webhook_in_background`, build the body and POST. **The topic in the payload must be `orders/paid` or `refunds/create`** — see §3. ### 9.2 Configurable endpoint via filter Hard-coding the URL means staging/prod swaps need a code change. Expose a filter so site admins (or a per-environment plugin) can flip it: ```php $endpoint = apply_filters( 'simply_dropins_payment_webhook_url', 'https://dropins.simplyclub.co.il/api/v1/transaction/order-webhook' ); $response = wp_remote_post($endpoint, [ 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($payload), 'timeout' => 15, ]); ``` Then in a staging site's `wp-config.php` or a small mu-plugin: ```php add_filter('simply_dropins_payment_webhook_url', fn() => 'https://simply-drop-ins-2.onrender.com/api/v1/transaction/order-webhook'); ``` ### 9.3 Build the payload from the order ```php $transaction_code = $order->get_meta('simply_transaction_code'); // saved from On.transactionCodeCreated if (!$transaction_code) return; // not a Club order — skip $payload = [ 'topic' => 'orders/paid', 'domain' => wp_parse_url(home_url(), PHP_URL_HOST), 'sc_transaction_code' => $transaction_code, 'order_id' => (string) $order->get_id(), 'order_number' => (string) $order->get_order_number(), 'customer_id' => (string) $order->get_customer_id(), 'total_price' => (string) $order->get_total(), 'items' => $this->build_items_from_order($order), 'discount_applications' => $this->build_discount_applications($order), ]; ``` When building `discount_applications`, **filter out fees whose ID starts with `sc_club_`** — those are SimplyClub's own discounts; reporting them back as external promos causes the backend to deduct points the customer didn't actually pay for. ### 9.4 Idempotency + admin retry meta Inspect `response['success']` and write the outcome to order meta so the WP admin can see Club sync status (and admins can retry on failure): ```php $body = json_decode(wp_remote_retrieve_body($response), true); if (!empty($body['success'])) { $order->update_meta_data('simplyclub_sync_status', 'success'); $order->add_order_note(__('Simply Club: Webhook sent successfully.', 'simply-dropins-wc')); } else { $order->update_meta_data('simplyclub_sync_status', 'failed'); $order->update_meta_data('simplyclub_sync_error', $body['data']['message'] ?? 'unknown'); $order->add_order_note(__('Simply Club: Webhook failed — ', 'simply-dropins-wc') . ($body['data']['errorMessage'] ?? '')); } $order->save_meta_data(); ``` Expose a **Retry Webhook** button on failed orders that re-enqueues the action: ```php add_action('wp_ajax_simply_retry_webhook', function () { check_ajax_referer('simply_retry_webhook', 'nonce'); if (!current_user_can('manage_woocommerce')) wp_send_json_error(); $order_id = absint($_POST['order_id'] ?? 0); if ($order_id) { as_enqueue_async_action('simply_send_webhook_action', [$order_id, 'order_paid']); wc_get_order($order_id)->update_meta_data('simplyclub_sync_status', 'pending'); } wp_send_json_success(); }); ``` ### 9.5 Cancellations & refunds — current state WooCommerce fires `woocommerce_order_status_cancelled` and `woocommerce_order_status_refunded`. The wp-dropins plugin currently posts `topic: orders/cancelled` and `topic: orders/refunded` for those — **both are silently skipped** (HTTP 200 with `{ skipped: true }`) against the production server today (see §3). Until SimplyClub adds those topics, the practical options are: 1. **Don't enqueue cancel/refund webhooks** — leave the SimplyClub session open and reconcile out-of-band, or 2. **For refunds**, ask support to enable `refunds/create` on your POI and send that topic instead (the WC plugin would need a small patch to remap). ### 9.6 Checklist (WordPress-specific) - [ ] Webhook URL goes through `apply_filters('simply_dropins_payment_webhook_url', ...)`. - [ ] HTTP call runs via `as_enqueue_async_action` (Action Scheduler) — never inline in `woocommerce_payment_complete`. - [ ] `simply_transaction_code` is captured in `WC()->session` and copied to order meta on order creation. - [ ] `discount_applications` does **not** include any fee whose ID starts with `sc_club_`. - [ ] `simplyclub_sync_status` is written on every webhook attempt (`pending` → `success` | `failed`). - [ ] Failed orders expose a Retry button using `wp_ajax_simply_retry_webhook` + a nonce. --- ## 10. Troubleshooting | Symptom | Likely cause | |---|---| | `400 Missing required fields` | One of `topic` / `domain` / `sc_transaction_code` / `order_id` (or `customer_id` for `orders/paid`) is missing or empty. | | `400 POI not found` | The `sc_transaction_code` resolved to a session whose POI no longer exists or has the wrong `accountId`. | | `500 Error processing webhook` (with `Session not found` in logs) | The `sc_transaction_code` did not match any session — a wrong, stale, or never-issued code. This is thrown as `Session not found` and surfaces as HTTP **500**, not 400. Verify you sent the exact code captured via `On.transactionCodeCreated`. | | `200` with `success: false` and `errorCode: 42` (or similar) | SimplyClub-side validation failed — check `errorMessage` and `transactionResponse` in the response. Do not retry. | | `200` with `skipped: true` | The webhook topic toggle is off for this POI. Ask support to enable it. | | Points awarded but no tag/metafield on the order | You didn't apply the `updates` from the response — admin will lose visibility. Wire up the response handler. | | Customer paid but no points ever appear | Webhook was never sent — verify your backend fires it on `order paid` and not just on `order created`. |