SimplyClub

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
EnvironmentBASE_URL
Productionhttps://dropins.simplyclub.co.il
Staginghttps://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 eventSend topicDefault state
Order paid / completedorders/paid✅ Always on
Order refunded (full or partial)refunds/createOff 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:

FieldTypeWhere it comes from
topic"orders/paid" | "refunds/create"You set this based on the host event.
domainstringThe host store’s domain (used in logs + admin links).
sc_transaction_codestringThe transactionCode your frontend captured via On.transactionCodeCreated(...) and persisted as order metadata.
order_idstring | numberThe native host order ID.
customer_idstring | numberThe native host customer ID. Required for orders/paid.

Strongly recommended:

FieldTypeWhy
order_numberstring | numberSurfaces in the SimplyClub admin and audit logs.
total_pricestringPersisted on the session — used for refund reconciliation.
items or line_itemsarrayRequired for orders/paid — line items the SimplyClub backend uses to build TranItems.
discount_applicationsarrayLets 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):

{
  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

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:

{
  success: boolean,
  data: { ... },
  error: any | null
}

The HTTP status alone is not enough — you must inspect success:

HTTPsuccessMeaningWhat to do
200trueClosed 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.
200true + data.skippedTopic toggle is off for this POI.No-op — but note that you tried.
200falseReached 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.
400falseMissing required field, or POI not found for the resolved session.Fix the request — never retry without changes.
500falseServer 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

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

// 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:

$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:

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

$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):

$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:

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 (pendingsuccess | failed).
  • Failed orders expose a Retry button using wp_ajax_simply_retry_webhook + a nonce.

10. Troubleshooting

SymptomLikely cause
400 Missing required fieldsOne of topic / domain / sc_transaction_code / order_id (or customer_id for orders/paid) is missing or empty.
400 POI not foundThe 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: trueThe webhook topic toggle is off for this POI. Ask support to enable it.
Points awarded but no tag/metafield on the orderYou didn’t apply the updates from the response — admin will lose visibility. Wire up the response handler.
Customer paid but no points ever appearWebhook was never sent — verify your backend fires it on order paid and not just on order created.