# Guide to Infinite Giving Webhooks: Features and Best Practices

Infinite Giving webhooks tell a partner platform when a nonprofit receives a non-cash gift: a donor pledges shares of stock, the shares are sold, the gift completes, or it fails. If your product embeds Infinite Giving so the nonprofits you serve can accept stock, crypto and donor-advised fund donations, webhooks are how those donations reach your donor records and reporting.

This guide covers how Infinite Giving's partner webhooks are configured and connected, the donation events and their payload, how to verify a delivery, and the practices that keep an Infinite Giving receiver reliable.

## What are Infinite Giving webhooks?

Infinite Giving's webhooks are for integration partners, not for individual nonprofits. Your company gets an Infinite Giving account and goes through a partnership kickoff with Infinite Giving's team. At kickoff, Infinite Giving configures your OAuth client and your webhook delivery: you supply the webhook URL, and they give you the signing secret. You don't set up either through the API.

Events start flowing for a nonprofit once it is connected to your partner app. The nonprofit's admin authorises your app through OAuth, and your backend calls the partner-subscriptions endpoint for that organisation. Organisations that sign up through your referral link may be connected automatically.

Every delivery is a JSON `POST` to the one webhook URL configured for your OAuth client, whichever nonprofit it concerns. Each carries `X-IG-Timestamp` and `X-IG-Signature`, an HMAC-SHA256 of the timestamp, a dot and the raw body in the form `sha256=<hex>`.

## Infinite Giving webhook features

| Feature | Details |
| --- | --- |
| Availability | Platform partners only, configured at partnership kickoff |
| Configuration | Webhook URL and signing secret set by Infinite Giving per OAuth client and environment |
| Connection | `POST /v1/organizations/:organization_id/partner-subscriptions` per nonprofit |
| Events | `donation:created`, `donation:updated` |
| Signature header | `X-IG-Signature: sha256=<hex>` |
| Timestamp header | `X-IG-Timestamp`, Unix seconds |
| Other headers | `X-IG-Webhook-Id` (the delivery ID, same as the envelope `id`), `X-IG-Event` |
| Scheme | HMAC-SHA256, hex |
| Signed content | `<X-IG-Timestamp>.<raw body>` |
| Secret | Shared out-of-band at kickoff, separate for sandbox and production |
| Retries | Up to 5 attempts in total, roughly 1 minute, 5 minutes, 30 minutes and 2 hours apart |
| Redelivery | `POST /v1/deliveries/:id/redeliver` |
| Replay window, source IPs | Not documented |

## Common events

| Event | Fires when |
| --- | --- |
| `donation:created` | A donation record is created |
| `donation:updated` | Status or material fields change after creation, for example to processing, complete, failed or canceled |

There is no `donation:completed` event. A finished gift arrives as `donation:updated` with `status: "complete"`.

Both events share an envelope and the same donation snapshot in `data`. Here is Infinite Giving's `donation:updated` example for a completed stock gift, trimmed:

```json
{
  "id": "F6G7H8I9J",
  "event": "donation:updated",
  "created_at": "2026-06-24T14:30:00.000Z",
  "data": {
    "id": "DON1234567",
    "object": "donation",
    "organization": { "id": "ORGABCDEFG", "name": "Example Nonprofit" },
    "type": "stock",
    "status": "complete",
    "identifier": "AAPL",
    "quantity": 10,
    "tax_deductible_amount": 2143.5,
    "recognition": "public",
    "campaign": { "id": "CAM1234567", "name": "Annual Fund" },
    "trade": {
      "executed_at": "2026-06-24T13:00:00.000Z",
      "settled_at": "2026-06-24T14:00:00.000Z",
      "fill_price": 215.0,
      "fill_quantity": 10,
      "sec_fee": 0.02
    },
    "donor": { "first_name": "Jane", "last_name": "Donor", "email": "jane@example.com" },
    "updated_at": "2026-06-24T14:30:00.000Z"
  }
}

```

A few fields need care. `recognition` is how the donor wants to be credited (`public`, `private` or `anonymous`), not a name. `source` means different things by type: the donor's brokerage for stock, the Stripe PaymentIntent ID for currency, and the wallet address for crypto. Gross sale proceeds are `fill_price * fill_quantity` minus the fee fields, and that won't equal `tax_deductible_amount`, which for stock is valued on the day the shares were received. `tax_deductible_amount` is set from creation for currency donations, but stays `null` for stock, crypto and DAF donations until they are valued.

## Setting up Infinite Giving webhooks

1. Contact Infinite Giving to start partner onboarding. Give them your HTTPS webhook URL and your OAuth callback URL.
2. At kickoff, receive your OAuth client ID and secret and your webhook signing secret. Store the signing secret server-side, for example as `INFINITE_GIVING_WEBHOOK_SECRET`. You normally get sandbox credentials first and a separate set for production.
3. When a nonprofit authorises your app, read its ID from `ig_organizations` in the token response and connect it:

```bash
curl -X POST "https://api.infinitegiving.com/v1/organizations/$ORGANIZATION_ID/partner-subscriptions" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

```

The call needs a token with the `webhooks:write` scope, and it is safe to retry. `DELETE /v1/partner-subscriptions/:id` stops delivery for that organisation, and connecting again restores it, so you can use it to pause.

If you receive Infinite Giving webhooks through Hookdeck, create a source with the Infinite Giving type and give its URL to Infinite Giving as your webhook URL. Infinite Giving webhooks are for integration partners, so paste the signing secret Infinite Giving gives you at partner kickoff into the source. The source accepts `POST` only, which is the method Infinite Giving uses. Because sandbox and production have separate secrets, use a separate source for each.

## Securing Infinite Giving webhooks

Read `X-IG-Timestamp` and `X-IG-Signature`. Build the signed bytes from the timestamp exactly as received, a literal `.`, and the raw request body. Compute an HMAC-SHA256 of them with the signing secret as a UTF-8 key, hex-encode it, and compare it with the digest after `sha256=` in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyInfiniteGivingSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;

  // The header is "sha256=<hex>". Accept a bare digest too.
  const digest = signatureHeader.trim().replace(/^sha256=/, '');
  if (!/^[0-9a-fA-F]{64}$/.test(digest)) return false; // check before Buffer.from(x, 'hex')

  // Optional replay window, off by default. Retries and replays resend the original timestamp.
  if (toleranceSeconds > 0) {
    if (!/^\d+$/.test(timestampHeader)) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestampHeader)) > toleranceSeconds) return false;
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${timestampHeader}.`, 'utf8'), rawBody]))
    .digest();
  const received = Buffer.from(digest, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/infinite-giving', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.INFINITE_GIVING_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyInfiniteGivingSignature(
    req.body,
    req.get('x-ig-signature'),
    req.get('x-ig-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id (also X-IG-Webhook-Id), then upsert event.data by its id.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

def verify_infinite_giving_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                                     tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    digest = signature_header.strip().removeprefix("sha256=")
    if not re.fullmatch(r"[0-9a-fA-F]{64}", digest):
        return False
    # Optional replay window, off by default.
    if tolerance_seconds > 0:
        if not (timestamp_header.isascii() and timestamp_header.isdigit()):
            return False
        if abs(time.time() - int(timestamp_header)) > tolerance_seconds:
            return False
    signed = timestamp_header.encode("utf-8") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(digest.lower().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_infinite_giving_signature(raw_body, request.headers.get("x-ig-signature"),
#                                  request.headers.get("x-ig-timestamp"),
#                                  os.environ["INFINITE_GIVING_WEBHOOK_SECRET"])

```

Infinite Giving's own sample compares the strings with `===`, which isn't constant-time. Use `crypto.timingSafeEqual` or `hmac.compare_digest` as above. Infinite Giving also says to use "the raw request body bytes" and not to re-serialise the JSON first.

Infinite Giving doesn't document a tolerance window for `X-IG-Timestamp`. The samples above leave the check off unless you pass a tolerance. Hookdeck applies no timestamp tolerance, and its retries and replays resend the original header, as does Infinite Giving's own retry of the same delivery, so a short window would reject legitimate deliveries.

> **Make Infinite Giving webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-IG-Signature` header over the timestamp and raw body, stores every donation event, and delivers it to your service with retries you control.

## Infinite Giving webhook limitations and pain points

### The URL and secret are set by Infinite Giving

**The Problem:** Moving your webhook endpoint, or adding a second consumer, means going back to Infinite Giving.

**Why It Happens:** The webhook URL and signing secret are configured for your OAuth client at kickoff. Subscriptions carry neither, so there is no API call to change them.

**Workarounds:**

* Choose a stable URL for kickoff, ideally one you can re-point without involving Infinite Giving.
* Keep the signing secret in a secret manager, separately for sandbox and production.

**How Hookdeck Can Help:** Give Infinite Giving a Hookdeck source URL at kickoff. Moving your service, or fanning donation events out to several services, then becomes a change to Hookdeck [connections](/docs/connections) rather than a request to Infinite Giving.

### One URL receives every nonprofit's donations

**The Problem:** Your receiver has to work out which customer each donation belongs to.

**Why It Happens:** Delivery always uses the single webhook URL for your OAuth client. Each nonprofit is identified only in the body, by `data.organization.id`.

**Workarounds:**

* Map `data.organization.id` to your own customer record when you connect the organisation.
* Log and alert on organisation IDs you don't recognise.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route on `data.organization.id`, so large customers or separate environments can go to their own destinations.

### Retries stop after about two and a half hours

**The Problem:** A longer outage on your side loses donation updates.

**Why It Happens:** Infinite Giving makes up to 5 attempts in total, the last roughly 2 hours after the one before. After that, the delivery is only in the delivery history.

**Workarounds:**

* Use `GET /v1/partner-subscriptions/:id/deliveries` after an outage and `POST /v1/deliveries/:id/redeliver` for anything you missed. This needs the `webhooks:write` scope.
* Fetch the donation from the API before acting on a status change you may have missed in between.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as it arrives and stores it, then delivers to your service with [retries](/docs/retries) you configure, so your own downtime doesn't depend on Infinite Giving's retry schedule. Hookdeck answers Infinite Giving with a `200` either way, and records any request that fails the signature check as failed verification, so it never reaches your destination.

### Amounts arrive in stages

**The Problem:** Reports show `null` or unexpected donation amounts.

**Why It Happens:** For stock, crypto and DAF gifts, `tax_deductible_amount` is `null` until the donation is valued, and the sale proceeds in `trade` differ from the tax-deductible value.

**Workarounds:**

* Treat `donation:updated` as the source of truth, and update amounts as they arrive.
* Keep tax-deductible value and sale proceeds as separate fields in your own records.

**How Hookdeck Can Help:** Hookdeck keeps every delivery, so you can trace how a donation's fields changed from `pledged` to `complete`.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Infinite Giving sent. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, use `await request.body()`.

### Deduplicate on `X-IG-Webhook-Id`

Infinite Giving says "the same delivery may be sent more than once" and recommends `X-IG-Webhook-Id`, which matches the envelope `id`, as the idempotency key. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Upsert donations by ID

Every event carries a full snapshot, so store `data` by `data.id` and compare `updated_at` rather than applying changes in arrival order.

### Acknowledge fast and process asynchronously

Infinite Giving asks for a quick 2xx and asynchronous processing. Verify, persist the event, return `200`, and update your records afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Infinite Giving sends partners `donation:created` and `donation:updated` events for every nonprofit they have connected, all to one URL set at kickoff. Verify `X-IG-Signature` as a hex HMAC-SHA256 of the timestamp, a dot and the raw body, deduplicate on `X-IG-Webhook-Id`, treat `donation:updated` with `status: "complete"` as completion, and use the delivery history to recover anything missed.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every Infinite Giving signature, and holds each donation event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Infinite Giving webhooks reliably in minutes.