> ## Documentation Index
> Fetch the complete documentation index at: https://plainrouter.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Send Meta conversions with Node.js and TypeScript

> Send Meta conversions through Plainrouter from Node.js or TypeScript, using consented purchase examples, stable event IDs, and delivery checks.

Send a purchase to Meta through Plainrouter using the [`@plainrouter/sdk`](https://www.npmjs.com/package/@plainrouter/sdk) package from server-side Node.js code. Plainrouter validates the event, normalizes and hashes supported identity fields, and sends eligible events to the Meta dataset connected to your workspace.

## Before you begin

You need:

* Node.js `22.22.2` or later in the Node 22 release line.
* A [Signals workspace secret](/docs/api/authentication#how-do-i-authenticate-the-rest-api) stored only on your server.
* An active [Meta destination](/docs/signals/connect-meta).
* A consent decision that permits downstream advertising use. See [Server-side tracking consent](https://plainrouter.com/library/server-side-consent).

## Install the Node.js SDK

Install [`@plainrouter/sdk`](https://www.npmjs.com/package/@plainrouter/sdk):

```bash theme={null}
npm install @plainrouter/sdk@0.5.1
```

The package is an ES module. Use `import` from a `.mjs` file or from a project whose `package.json` contains `"type": "module"`.

## Send a purchase

Store `PLAINROUTER_SIGNAL_SECRET` in your server environment. The SDK option `signalTrackerSecret` is its compatibility name; it expects a Signals workspace secret. Keep the configured client out of browser bundles.

Pass the recorded three-field `consent` object from your order or CMP records. The function refuses partial or denied advertising consent; it does not obtain consent. Supply the actual purchase time and consent-capture time from your order records. Reuse a stable, non-personal order ID for retries, and send money as a decimal string such as `"49.90"`.

<Tabs>
  <Tab title="JavaScript">
    ```js theme={null}
    import {
      configurePlainrouter,
      createEvent,
    } from "@plainrouter/sdk";

    const signalSecret = process.env.PLAINROUTER_SIGNAL_SECRET;
    if (!signalSecret) throw new Error("PLAINROUTER_SIGNAL_SECRET is required");

    configurePlainrouter({ signalTrackerSecret: signalSecret });

    export async function sendPurchase(order) {
      const fields = ["ad_storage", "ad_user_data", "ad_personalization"];
      if (!fields.every((field) => order.consent?.[field] === "granted")) {
        throw new Error("Recorded advertising consent is required");
      }
      if (!order.id) throw new Error("A stable order ID is required");
      if (!Number.isFinite(order.paidAt.getTime())) {
        throw new Error("A valid original payment time is required");
      }
      const eventId = `purchase:${order.id}`;

      const { data, response } = await createEvent({
        body: {
          event_id: eventId,
          event_name: "Purchase",
          event_time: Math.floor(order.paidAt.getTime() / 1000),
          event_source: "https://shop.example/checkout/success",
          action_source: "website",
          consent_basis: "consent",
          consent: {
            ad_storage: order.consent.ad_storage,
            ad_user_data: order.consent.ad_user_data,
            ad_personalization: order.consent.ad_personalization,
            captured_at: order.consentCapturedAt.toISOString(),
            source: "checkout_cmp",
          },
          user_data: {
            email: order.email,
            phone: order.phone,
            client_ip_address: order.ipAddress,
            client_user_agent: order.userAgent,
          },
          click_ids: {
            ...(order.fbp ? { fbp: order.fbp } : {}),
            ...(order.fbc ? { fbc: order.fbc } : {}),
          },
          value_data: {
            value: order.total,
            currency: order.currency,
            order_id: order.id,
            contents: order.items.map((item) => ({
              id: item.sku,
              quantity: item.quantity,
            })),
            num_items: order.items.reduce((total, item) => total + item.quantity, 0),
          },
        },
        throwOnError: true,
      });

      console.log({ status: response.status, ...data });
      return data;
    }
    ```
  </Tab>

  <Tab title="TypeScript">
    Use the generated `CreateEventData` request type for the consented event branch:

    ```ts theme={null}
    import {
      configurePlainrouter,
      createEvent,
      type CreateEventData,
      type CreateEventResponse,
    } from "@plainrouter/sdk";

    export type PurchaseInput = {
      orderId: string;
      paidAt: Date;
      total: string;
      currency: string;
      email: string;
      phone?: string;
      fbp?: string;
      fbc?: string;
      consentCapturedAt: Date;
      consent: {
        ad_storage: "granted" | "denied";
        ad_user_data: "granted" | "denied";
        ad_personalization: "granted" | "denied";
      };
    };

    type ConsentedEventBody = Extract<
      CreateEventData["body"],
      { consent_basis: "consent" }
    >;

    const signalSecret = process.env.PLAINROUTER_SIGNAL_SECRET;
    if (!signalSecret) throw new Error("PLAINROUTER_SIGNAL_SECRET is required");

    configurePlainrouter({ signalTrackerSecret: signalSecret });

    function purchaseBody(purchase: PurchaseInput): ConsentedEventBody {
      const consent = purchase.consent;
      if (
        consent.ad_storage !== "granted" ||
        consent.ad_user_data !== "granted" ||
        consent.ad_personalization !== "granted"
      ) {
        throw new Error("Recorded advertising consent is required");
      }
      if (!purchase.orderId) throw new Error("A stable order ID is required");
      if (!Number.isFinite(purchase.paidAt.getTime())) {
        throw new Error("A valid original payment time is required");
      }
      return {
        event_id: `purchase:${purchase.orderId}`,
        event_name: "Purchase",
        event_time: Math.floor(purchase.paidAt.getTime() / 1000),
        event_source: "https://shop.example/checkout/success",
        action_source: "website",
        consent_basis: "consent",
        consent: {
          ad_storage: consent.ad_storage,
          ad_user_data: consent.ad_user_data,
          ad_personalization: consent.ad_personalization,
          captured_at: purchase.consentCapturedAt.toISOString(),
          source: "checkout_cmp",
        },
        user_data: {
          email: purchase.email,
          ...(purchase.phone ? { phone: purchase.phone } : {}),
        },
        click_ids: {
          ...(purchase.fbp ? { fbp: purchase.fbp } : {}),
          ...(purchase.fbc ? { fbc: purchase.fbc } : {}),
        },
        value_data: {
          value: purchase.total,
          currency: purchase.currency,
          order_id: purchase.orderId,
        },
      };
    }

    export async function sendPurchase(
      purchase: PurchaseInput,
    ): Promise<CreateEventResponse> {
      const result = await createEvent({ body: purchaseBody(purchase) });

      if (!result.response?.ok || !result.data) {
        throw new Error(`Plainrouter request failed (HTTP ${result.response?.status ?? "no response"})`);
      }

      return result.data;
    }
    ```
  </Tab>
</Tabs>

Only include identity and browser identifiers you have permission to use. Omit `fbp` or `fbc` when unavailable; see [browser identifier formats](https://plainrouter.com/library/fbp-fbc).

## Validate a TypeScript payload

The SDK exports a runtime schema when data crosses an untrusted boundary:

```ts theme={null}
import { zCreateEventBody } from "@plainrouter/sdk";

const parsed = zCreateEventBody.safeParse(untrustedPayload);

if (!parsed.success) {
  throw new Error(parsed.error.message);
}
```

Passing this schema check does not establish consent or final API acceptance. Plainrouter still validates the submitted event.

## Retry without creating another event

Keep the same `event_id` when a timeout or queue retry repeats the same logical purchase:

* A new event returns HTTP `202` with `duplicate: false`.
* A previously accepted `event_id` returns HTTP `200` with `duplicate: true`.
* Changing the ID on each retry defeats idempotency and can create duplicate conversions.

The SDK sends a server event; it does not trigger the managed browser Pixel. A separate browser `signalq("track", ...)` call generates its own ID and cannot accept the `purchase:${order.id}` ID used here. Choose the backend or the [managed browser purchase recipe](/docs/signals/track-events#avoid-duplicate-meta-events) as the purchase owner. Do not also send a purchase from Stripe verified revenue for the same checkout.

For a durable backend integration:

1. Confirm settlement on your server and read the recorded consent decision. A browser success-page visit is insufficient.
2. Persist one event payload per paid order, including `event_id`, the original purchase time, decimal amount, and the applicable consent snapshot. Use a unique record in your database to prevent concurrent jobs creating separate purchases.
3. Send that stored payload from your job. On an uncertain timeout, retry the same ID and payload; do not rebuild it with the current time or fresh identity data. Check that advertising use is still permitted before a later retry.
4. Mark ingestion complete after `202` or a duplicate `200`; inspect destination delivery separately. A duplicate receipt does not update or enrich the first accepted event.

These database and job steps belong to your application. SDK request idempotency does not make a payment operation idempotent. See [Meta event-ID deduplication](https://plainrouter.com/library/meta-event-id-deduplication) for the distinction between API retries and browser/server pairing.

## Confirm delivery

The `202` response means Plainrouter accepted the event for processing. It does not mean Meta has accepted the destination delivery yet.

Use [Signal health and performance](/docs/signals/health-and-performance) or retrieve the event with `getEvent` to inspect its delivery state. During setup, you can also use Meta Test Events mode through the destination operations in the [Conversion API reference](/docs/api/conversions).

```ts theme={null}
import { getEvent } from "@plainrouter/sdk";

export async function deliveryStatuses(eventId: string) {
  const result = await getEvent({ path: { event: eventId } });
  if (!result.response?.ok || !result.data) {
    throw new Error(`Event lookup failed (HTTP ${result.response?.status ?? "no response"})`);
  }
  return result.data.deliveries.map((delivery) => delivery.status);
}
```

Pass the `event_id` returned by `sendPurchase`. An `accepted` delivery confirms Meta acceptance; `queued`, `sent`, or `retrying` remains pending. An empty list is not acceptance. For `failed:*` or `skipped:*`, inspect the [delivery explanation](/docs/reference/statuses-and-terms#destination-delivery) before replaying anything. Handle `401` by checking credentials, `404` by checking ID, scope, and retention, and `422` by correcting the request fields.

## Implementation references

* [`configurePlainrouter` source at SDK `0.5.1`](https://github.com/plainrouter/sdk/blob/v0.5.1/packages/sdk/src/config.ts)
* [`createEvent` generated operation at SDK `0.5.1`](https://github.com/plainrouter/sdk/blob/v0.5.1/packages/sdk/src/generated/sdk.gen.ts)
* [`CreateEventData` request type at SDK `0.5.1`](https://github.com/plainrouter/sdk/blob/v0.5.1/packages/sdk/src/generated/types.gen.ts)
* [Plainrouter Conversion API reference](/docs/api/conversions)
