pixellint

CAPI · PMs · marketers · engineers

A refund is a measurement event

Finance already knows the order was reversed. Ads does not, unless you tell it. Partial refunds need a partial value, not a second Purchase of zero.

Same transaction, opposite sign

transaction_id / order_id must match the original Purchase. A new UUID makes a refund for a purchase the platform never saw. GA4 refund needs currency, value, and transaction_id (vendor.google-analytics.body.refund_requires_ecommerce_fields). The value is the amount refunded, in the same currency definition you used on purchase (tax in or tax out).

Meta does not treat a second Purchase with value 0 as a refund. Send a refund-style event if you use one, or adjust in offline uploads the way the ads account is set up. Do not invent a Purchase of -84.50 unless the vendor documented negative value. Most did not.

Partial refunds

A $100 order with a $20 refund is a $20 refund event, not a $0 Purchase and not a $100 refund. Line-item refunds should carry the items that came back if the vendor's refund event accepts items (GA4 does). Otherwise the platform can only net the money, not the SKU.

Use the same cents policy as Purchase. If Purchase sent 100.00 and refund sends 2000 (cents), you have not netted the order. You have created a $2000 hole or a $2000 miracle, depending on sign.

Timing and age

Refunds can land after the attribution window. Send them anyway if the vendor accepts late events. Meta event_time can be up to 7 days old; a refund on day 20 may drop. LinkedIn allows more age on conversionHappenedAt (90 days) but that is a different API. If they reject on age, keep a finance-side ROAS that the ads UI will not match, and say so.

event_time on the refund is the refund instant, not the original purchase instant. Reusing the purchase timestamp to sneak under the 7 day cap attributes the refund to the purchase day and may still be dropped if send time is too late. Do not rewrite the clock to cheat the cap.

Cancels before capture

An authorized-then-voided payment should not have been a Purchase. If you fired on authorize, fire refund or a documented cancel, or stop firing Purchase until capture. If you fire Purchase only on capture, you already did the right thing and a void is a no-op in ads.

Checkout abandonment is not a refund. InitiateCheckout without Purchase is the funnel. Sending Refund because the session died is how you mint refunds for orders that never existed.

Retries and duplicate refunds

A refund webhook will redeliver. Reuse a refund event_id (order-1842-refund, or a Stripe refund id), not a new UUID per attempt. Two refund events for one $20 return is a $40 correction. The same idempotency rules as Purchase apply, with a different event name so they do not dedup against each other.

Do not replay the original Purchase with a new id as a 'correction'. That is a second conversion. Corrections are refund events or vendor-specific adjustment uploads.

GA4 refund is a first-class event

Same transaction_id as purchase. Value is the amount returned. HTTP 204 still will not check this; the pack will.

// Original purchase
{
  "events": [{
    "name": "purchase",
    "params": {
      "currency": "USD",
      "value": 84.50,
      "transaction_id": "1842",
      "items": [{ "item_id": "sku-9", "price": 84.50, "quantity": 1 }]
    }
  }]
}

// Partial refund of 20.00, same transaction_id
{
  "events": [{
    "name": "refund",
    "params": {
      "currency": "USD",
      "value": 20.00,
      "transaction_id": "1842",
      "items": [{ "item_id": "sku-9", "price": 20.00, "quantity": 1 }]
    }
  }]
}

pixellint validate json @refund.json --rulepack vendor/google-analytics
# vendor.google-analytics.body.refund_requires_ecommerce_fields

// Wrong: second Purchase of 0 with a new event_id. That is not a refund.
{ "event_name": "Purchase", "event_id": "order-1842-b", "custom_data": { "value": 0, "currency": "USD" } }

Check the artifact

Paste the pixel URL or JSON body into the playground. Same engine as pixellint validate. Nothing leaves the tab.