# Conversions for Meta and Google Ads

When an ad brings in someone who ends up buying, booking or leaving their details, the ad platform needs to know in order to optimize. With `met.conversions` you report that fact from your system —the one that knows the payment came in— and Met carries it to Meta (Conversions API) and to Google Ads.

**You do not emit directly.** Everything you report goes through the **conversion gate**, the same one the conversions proposed by the Met and by flows go through. The gate checks that there is an ad behind it, that the event is on the business's list and that the fact is backed up, and it tells you what it decided. That is what keeps a buggy integration from inflating campaign results.

There are two paths:

| Method | What for | Identified by |
|---|---|---|
| `conversions.report()` | A conversion by a **contact** who came in through a WhatsApp, Messenger or Instagram ad. | `contact_id` |
| `conversions.reportWeb()` | The server-side mirror of an event from your site's **Pixel**. | The Pixel's `event_id` and the visitor's cookies or details |

## Before you start

- Your key needs `conversions:write`. **It does not work with a test key** (`403` `test_mode_restricted`): every accepted conversion is a real event in the ad account.
- The business has to have conversions turned on in the panel, with its list of events. Without that, everything comes out `discarded` with `reason: "integration_inactive"`.
- Both methods return `201` even when the gate discards: **always read `decision`**.

## A contact's conversion

```ts
const r = await met.conversions.report({
  contact_id: 4821,
  event_name: 'Purchase',
  value: 250000,
  currency: 'COP',
  bucket: 'pedido-10293',
});

if (r.decision !== 'accepted') console.log('Not sent:', r.decision, r.reason);
```
```python
r = met.conversions.report(
    contact_id=4821,
    event_name="Purchase",
    value=250000,
    currency="COP",
    bucket="pedido-10293",
)
if r["decision"] != "accepted":
    print("Not sent:", r["decision"], r.get("reason"))
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/conversions \
  -H "Authorization: Bearer $METEOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contact_id":4821,"event_name":"Purchase","value":250000,"currency":"COP","bucket":"pedido-10293"}'
```

| Field | What it is |
|---|---|
| `contact_id` | The CRM contact (see [CRM and contacts](contacts.html)). It has to have come in through an ad: otherwise it comes out `no_attribution`. |
| `event_name` | An event on the business's list. For WhatsApp, Meta asks for messaging names: `Purchase`, `LeadSubmitted`, `InitiateCheckout`… not the web Pixel ones (`Lead`, `Contact`). |
| `value`, `currency` | Value and three-letter ISO 4217 currency (`COP`, `USD`). A purchase requires them. |
| `evidence` | What you are basing it on, in one sentence ("paid invoice 10293"). Almost every event requires it. |
| `bucket` | What makes the conversion unique: the order id, the appointment id. Without it, the unit is the contact: the same contact only converts once per event. |

### What the gate requires

| Event | For it to go out |
|---|---|
| `Purchase` | `value` greater than zero and a valid `currency`. |
| `LeadSubmitted`, `InitiateCheckout`, `AddToCart`, `Schedule`, `ReviewProvided`, `QualifiedLead` | `evidence`. |
| Any other event on the list | `evidence`. |

On top of that, a contact does not accept more than 10 conversions in 24 hours (the default cap): it is the brake for a flow stuck in a loop.

## A web conversion (Pixel)

If your site fires the Pixel in the browser, also report it from your server with the **same** `event_id`: Meta merges the two and does not count it twice.

```ts
// In the browser: fbq('track', 'Purchase', { value: 250000, currency: 'COP' }, { eventID: 'ord-10293' });
const r = await met.conversions.reportWeb({
  event_name: 'Purchase',
  event_id: 'ord-10293',
  fbp: req.cookies._fbp,
  fbc: req.cookies._fbc,
  email: customer.email,
  event_source_url: 'https://mystore.co/thanks',
  client_ip_address: req.ip,
  client_user_agent: req.headers['user-agent'],
  value: 250000,
  currency: 'COP',
});
```
```python
r = met.conversions.report_web(
    event_name="Purchase",
    event_id="ord-10293",
    fbp=request.cookies.get("_fbp"),
    email=customer_email,
    client_ip_address=visitor_ip,
    client_user_agent=visitor_user_agent,
    value=250000,
    currency="COP",
)
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/conversions/web \
  -H "Authorization: Bearer $METEOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_name":"Purchase","event_id":"ord-10293","fbp":"fb.1.1730000000.123","gclid":"Cj0KCQ…","value":250000,"currency":"COP"}'
```

- At least one way of recognizing the visitor is required: `fbp`, `fbc`, `email`, `phone` or `external_id` for Meta; `gclid`, `gbraid`, `wbraid`, `email`, `phone` or `external_id` for Google Ads. With none, it comes out `no_match_key`.
- `email`, `phone` and `external_id` travel in plain text to Met and leave Met **as SHA-256 hashes**; `fbp`, `fbc` and Google's click identifiers go as they are.
- `client_ip_address` and `client_user_agent` are the **visitor's**, not your server's: they improve matching.
- This path does not ask for `evidence`: the fact already happened in the browser.
- If the visitor came in through a Google ad, also pass `gclid` (or `gbraid` / `wbraid`), taken from the landing URL.

## What it returns

```json
{ "decision": "discarded", "reason": "uncorroborated" }
```

| `decision` | What happened |
|---|---|
| `accepted` | It passed the gate and is on its way to the platform. `eventId` is the key it travels with. |
| `pending_corroboration` | It is waiting for a confirmation from the CRM. It does not happen through the API —your system is the source of the fact—: it happens to a purchase or a qualified lead proposed by the Met or a flow. |
| `duplicate` | An identical one already went out: same contact, event and `bucket`, or the same `event_id` on the web. It is not sent twice. |
| `discarded` | It does not go out. `reason` says why. |

| `reason` | What to do |
|---|---|
| `integration_inactive` | The business does not have conversions turned on. |
| `no_attribution` | The contact did not come in through an ad (or their click is no longer usable). |
| `not_in_taxonomy` | `event_name` is not on the business's list. |
| `uncorroborated` | `evidence` was missing, or `value` and `currency` on a purchase. |
| `rate_limited` | The contact already had 10 accepted conversions in 24 hours. |
| `missing_waba` | The contact is on WhatsApp and the WhatsApp Business account is missing from the configuration. |
| `no_match_key` | A web conversion with no way of recognizing the visitor. |

## Hearing about delivery

`accepted` says it left the gate; delivery to Meta comes afterwards. Subscribe to the events (scope `conversions:read`; see [Webhooks](webhooks.html)):

| Event | When |
|---|---|
| `conversion.sent` | Meta received the conversion. Carries `event_id`, `event_name` and Meta's `fbtrace_id`. |
| `conversion.discarded` | The gate discarded a conversion, whether you, the Met or a flow proposed it. Carries `reason`. |

## Common errors

| Code | What happened |
|---|---|
| `400` `validation_error` | `contact_id` or `event_name` is missing (`event_id` on the web), or a field exceeds its maximum length. |
| `403` `missing_scope` | The key does not carry `conversions:write`. |
| `403` `test_mode_restricted` | A `met_test_` key does not report conversions. |
