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

# Postbacks

> How Offerzy tells your server to credit or debit a user, with every parameter and a complete handler in Node.js, PHP and Python.

When a user completes an offer and we validate the conversion, we send a `GET` request to your placement's **Postback URL**. We send another one if a conversion is later reversed. Your server credits or debits the user and answers with HTTP 200.

<Warning>
  Set a postback URL before you go live. Without one, your users can't be credited automatically.
</Warning>

## Your postback URL

Enter a plain URL in the placement's **Settings**, for example:

```text theme={null}
https://your-server.com/offerzy/postback
```

We add the parameters below to it. Query parameters of your own, such as `?token=abc`, are kept, as long as they don't use the same names as ours.

## The request

```http theme={null}
GET https://your-server.com/offerzy/postback?user_id=user123&status=1&reward=1500&payout=1.5&transaction_id=ofz_ne48213&offer_id=8c1f0e2a&offer_name=Kingdom%20Quest&ip=203.0.113.10&country=US&signature=5f2b...
```

<ParamField query="user_id" type="string">
  The `user_id` you passed in the offerwall link.
</ParamField>

<ParamField query="status" type="integer">
  `1` to credit the user for a validated conversion, `2` to take the reward back after a [reversal](/postbacks/reversals).
</ParamField>

<ParamField query="reward" type="number">
  The amount to credit in your currency, based on your [exchange rate](/offerwall/currency). Negative on a reversal.
</ParamField>

<ParamField query="payout" type="number">
  Your earnings for the conversion in USD. Negative on a reversal.
</ParamField>

<ParamField query="transaction_id" type="string">
  Unique ID of the conversion. A reversal repeats the `transaction_id` of the conversion it reverses. Treat it as an opaque string.
</ParamField>

<ParamField query="offer_id" type="string">
  ID of the completed offer.
</ParamField>

<ParamField query="offer_name" type="string">
  Name of the completed offer, handy for your users' reward history.
</ParamField>

<ParamField query="ip" type="string">
  The user's IP address when they clicked the offer.
</ParamField>

<ParamField query="country" type="string">
  The user's two-letter country code when they clicked the offer, for example `US`.
</ParamField>

<ParamField query="signature" type="string">
  Proof the request came from us. Always check it; see [Verify postbacks](/postbacks/security).
</ParamField>

<Note>
  Offers with several goals, such as reaching levels 10, 20 and 30 in a game, send one postback per completed goal. Each goal has its own `transaction_id`.
</Note>

## Your response

Answer with any `2xx` status within 15 seconds. The response body doesn't matter. Anything else, including a timeout, counts as a failure and we [retry automatically](/postbacks/testing#retries).

* Answer `2xx` for a postback you have already processed, so we stop retrying, but don't credit it again.
* Answer `403` if the signature doesn't match.
* Keep it fast: if crediting takes long, store the postback and finish the work in the background.

## A complete handler

Each example checks the signature, applies every `transaction_id` and `status` pair only once, and credits or debits the user. `reward` is negative on a reversal, so adding it to the balance debits the user.

<CodeGroup>
  ```js Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const SECRET_KEY = process.env.OFFERZY_SECRET_KEY; // Dashboard > Settings > Secret key

  app.get("/offerzy/postback", async (req, res) => {
    const { user_id, status, reward, transaction_id, offer_id, signature } = req.query;

    // 1. Check the request came from Offerzy.
    const expected = crypto
      .createHash("md5")
      .update(`${user_id}|${reward}|${offer_id}|${SECRET_KEY}`)
      .digest("hex");
    const valid =
      typeof signature === "string" &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!valid) return res.status(403).send("invalid signature");

    // 2. Apply each (transaction_id, status) once: retries can repeat a postback.
    //    `db` stands for your own database code.
    await db.transaction(async (tx) => {
      const isNew = await tx.recordPostbackOnce({ transaction_id, status, user_id, reward });
      if (isNew) await tx.addToBalance(user_id, Number(reward));
    });

    // 3. Any 2xx within 15 seconds marks the postback as delivered.
    res.send("ok");
  });
  ```

  ```php PHP theme={null}
  <?php
  $secretKey = getenv('OFFERZY_SECRET_KEY'); // Dashboard > Settings > Secret key

  $userId        = $_GET['user_id'] ?? '';
  $status        = $_GET['status'] ?? '';
  $reward        = $_GET['reward'] ?? '';
  $transactionId = $_GET['transaction_id'] ?? '';
  $offerId       = $_GET['offer_id'] ?? '';
  $signature     = $_GET['signature'] ?? '';

  // 1. Check the request came from Offerzy.
  $expected = md5("{$userId}|{$reward}|{$offerId}|{$secretKey}");
  if (!hash_equals($expected, $signature)) {
      http_response_code(403);
      exit('invalid signature');
  }

  // 2. Apply each (transaction_id, status) once: retries can repeat a postback.
  //    The primary key on (transaction_id, status) makes INSERT IGNORE skip repeats (MySQL).
  $pdo->beginTransaction();
  $insert = $pdo->prepare(
      'INSERT IGNORE INTO offerzy_postbacks (transaction_id, status, user_id, reward) VALUES (?, ?, ?, ?)'
  );
  $insert->execute([$transactionId, $status, $userId, $reward]);
  if ($insert->rowCount() === 1) {
      $pdo->prepare('UPDATE users SET balance = balance + ? WHERE id = ?')->execute([$reward, $userId]);
  }
  $pdo->commit();

  // 3. Any 2xx within 15 seconds marks the postback as delivered.
  echo 'ok';
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import os
  from decimal import Decimal

  from flask import Flask, request

  app = Flask(__name__)
  SECRET_KEY = os.environ["OFFERZY_SECRET_KEY"]  # Dashboard > Settings > Secret key


  @app.get("/offerzy/postback")
  def offerzy_postback():
      q = request.args

      # 1. Check the request came from Offerzy.
      raw = f"{q.get('user_id', '')}|{q.get('reward', '')}|{q.get('offer_id', '')}|{SECRET_KEY}"
      expected = hashlib.md5(raw.encode()).hexdigest()
      if not hmac.compare_digest(expected, q.get("signature", "")):
          return "invalid signature", 403

      # 2. Apply each (transaction_id, status) once: retries can repeat a postback.
      #    record_postback_once and add_to_balance stand for your own database code.
      if record_postback_once(q["transaction_id"], q["status"], q["user_id"], q["reward"]):
          add_to_balance(q["user_id"], Decimal(q["reward"]))

      # 3. Any 2xx within 15 seconds marks the postback as delivered.
      return "ok"
  ```
</CodeGroup>

<Accordion title="A table for processed postbacks">
  The primary key on `transaction_id` and `status` lets you store a conversion and its reversal, and rejects repeats of either:

  ```sql theme={null}
  CREATE TABLE offerzy_postbacks (
    transaction_id VARCHAR(64)    NOT NULL,
    status         SMALLINT       NOT NULL, -- 1 credit, 2 reversal
    user_id        VARCHAR(100)   NOT NULL,
    reward         DECIMAL(18, 4) NOT NULL,
    received_at    TIMESTAMP      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (transaction_id, status)
  );
  ```
</Accordion>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.