Guide to Ramp Webhooks: Features and Best Practices
Ramp webhooks tell your systems when something happens in a Ramp account: a card transaction is authorised or settles, a bill is approved or paid, a reimbursement needs review, or a record is ready to sync to the general ledger. If you build ERP integrations, approval tooling or finance automation on top of Ramp, the corporate card and spend management platform, webhooks are how you learn about those changes without polling the Developer API.
This guide covers how Ramp subscriptions are created and activated, how deliveries are signed, the events you can subscribe to, how Ramp retries, and the best practices for running a Ramp receiver in production.
What are Ramp webhooks?
A Ramp webhook is a subscription created through the Developer API. You send POST /developer/v1/webhooks with an endpoint URL and a list of event types, and Ramp returns the subscription along with a secret for verifying its deliveries. There is no dashboard form for this, so every subscription is created with an access token.
A new subscription does nothing at first. It starts in the pending_verification state, and Ramp sends your URL a one-off verification request containing a challenge string. Business events only start flowing after you submit that challenge back to Ramp's API.
Payloads are deliberately thin. Each event names its type and the ID of the affected resource, and you call the API for the details. A transactions.cleared event, for example, does not include the amount.
Ramp webhook features
| Feature | Details |
|---|---|
| Configuration | API only: POST https://api.ramp.com/developer/v1/webhooks |
| Scopes | The token needs the read scope for each event's resource, such as transactions:read or bills:read |
| Activation | A signed webhooks.verification POST, then the challenge is submitted to POST /developer/v1/webhooks/{webhook_ |
| Signature header | X-Ramp-Signature |
| Scheme | HMAC-SHA256, hex digest, no prefix |
| Signed content | The raw request body, nothing else |
| Secret | One per subscription, returned as secret in the create response |
| Event ID | Body id, repeated in X-Ramp-Webhook-ID and constant across retries |
| Custom headers | Up to five additional_headers, sent on verification requests and deliveries |
| Timeout | 10 seconds |
| Retries | Up to 10 attempts in total for 429, 5xx, timeouts and connection errors |
| Ordering | Not guaranteed |
| Testing | POST /developer/v1/webhooks/mock-webhook-event |
Ramp does not publish a list of source IP addresses for webhook traffic, so the signature is how you authenticate a delivery.
Common events
Ramp's guide lists 46 business events grouped by resource, and the API's own enum adds draft_bills.created and payments.updated. Each group needs the matching read scope.
| Resource | Events |
|---|---|
| Transactions | transactions.authorized, transactions.cleared, transactions.declined, transactions.ready_, transactions.ready_, transactions.receipt_, transactions.sync_, transactions.synced, transactions.body_, transactions.all_ |
| Bills | bills.created, bills.updated, bills.approved, bills.rejected, bills.paid, bills.archived, bills.ready_to_sync |
| Reimbursements | reimbursements.ready_, reimbursements.ready_, reimbursements.sync_, reimbursements.batch_ |
| Purchase orders | purchase_orders.created, purchase_orders.updated, purchase_orders.archived |
| Vendors and agreements | vendors.activated, vendors.approved, vendors.updated, vendor_, vendor_, vendor_ and others |
| Requests | spend_requests.created, spend_, and seven unified_requests.* events |
| Other | applications.status_, entities.created, item_receipts.created, users.invite_accepted |
Two values arrive in the type field that are not business events. webhooks.verification is the activation challenge, and tests.test_event is a mock event for testing only.
Some events need care. transactions.cleared fires for purchases, refunds and reversals alike. bills.updated covers edits such as invoice numbers, PO matching and accounting fields, but not approvals or payments, which have their own events.
Every payload has the same five fields. Here is Ramp's transactions.cleared example:
{
"id": "a461a658-8d78-4e66-b1fd-9c818bcfea6b",
"type": "transactions.cleared",
"created_at": "2025-04-01T12:00:00Z",
"business_id": "945c7d8f-6c35-4d63-9c1c-79e67c9cee73",
"object": {
"id": "3aa9eed1-c793-4b52-a502-e7f2a7e4c1b2"
}
}
id is the event ID. business_id identifies the Ramp business, which matters if your app serves several Ramp customers. object holds the resource ID plus any event-specific metadata Ramp supplies. To get the transaction itself, call GET /developer/v1/transactions/{transaction_. Ramp says to read the amount from entity_amount.value, which is signed and negative for refunds and credits, and not from the deprecated amount field.
Setting up Ramp webhooks
- Make sure your access token has the read scope for every event you plan to subscribe to.
- Deploy an HTTPS endpoint that accepts
POSTrequests, returns a2xxand responds within 10 seconds. - Create the subscription:
curl -X POST "https://api.ramp.com/developer/v1/webhooks" \
-H "Authorization: Bearer $RAMP_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"endpoint_url": "https://example.com/webhooks/ramp",
"event_types": ["transactions.cleared", "bills.paid"]
}'
- Store the
secretfrom the response server-side, for example asRAMP_WEBHOOK_SECRET. Each subscription has its own. - Ramp sends the verification request to your URL:
{
"type": "webhooks.verification",
"challenge": "ramp_challenge_a8f5f167f44f4964e6c998dee827110c"
}
- Return any
2xx, then submit the challenge:
curl -X POST "https://api.ramp.com/developer/v1/webhooks/$WEBHOOK_ID/verify" \
-H "Authorization: Bearer $RAMP_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"challenge": "ramp_challenge_a8f5f167f44f4964e6c998dee827110c"}'
A successful call returns {"success": true} and the subscription becomes active.
The challenge is not echoed in the response, as it is with many other providers. Your endpoint only has to accept the request, and the second step is an API call made by someone with the access token. In practice that means your receiver needs to log or store the challenge so you can submit it.
The create call also accepts an additional_headers object of up to five headers, with names up to 100 characters and values up to 1,000. Ramp reserves Host, User-Agent, Content-Length, Content-Type, Accept, Accept-Encoding, Connection, Upgrade and any name starting with X-Forwarded, X-Real or X-Original.
Ramp has no way to edit a subscription's URL. To move to a new endpoint, delete the subscription, create a new one and verify it again.
Securing Ramp webhooks
Every request Ramp sends includes X-Ramp-Signature, a hex-encoded HMAC-SHA256 of the raw request body, keyed with the subscription's secret used as a plain string. There is no timestamp, no version prefix and nothing else in the signed content. Ramp calls validation optional, for the sake of no-code tools, but recommends it for any production receiver.
Verify the raw bytes before you parse anything. Ramp warns that rebuilding the body with JSON.stringify(req.body) can change formatting or key order and break the signature.
const crypto = require('crypto');
const express = require('express');
function verifyRampSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/ramp', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.RAMP_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
if (!verifyRampSignature(req.body, req.get('x-ramp-signature'), secret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
if (event.type === 'webhooks.verification') {
// Store event.challenge, then submit it to /developer/v1/webhooks/{id}/verify.
console.log('Ramp verification challenge received');
return res.sendStatus(200);
}
// Deduplicate on event.id, queue the work, and fetch the resource by event.object.id.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
def verify_ramp_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_ramp_signature(raw_body, request.headers.get("x-ramp-signature"),
# os.environ["RAMP_WEBHOOK_SECRET"])
A valid signature is always 64 hex characters. Ramp has no verification SDK, so this manual check is the approach in every language.
Make Ramp webhooks production-ready. Hookdeck Event Gateway verifies
X-Ramp-Signature, accepts every delivery inside Ramp's 10 second window, and durably queues transaction, bill and reimbursement events before your service sees them.
Ramp webhook limitations and pain points
Activation is a two-step, out-of-band process
The Problem: A subscription is created successfully, but no events ever arrive.
Why It Happens: New subscriptions sit in pending_verification until the challenge is submitted to the verify endpoint. Returning 200 to the verification request is not enough on its own. If your receiver acknowledged the challenge without recording it, nothing tells you what to submit.
Workarounds:
- Handle
type === 'webhooks.verification'explicitly and log or store thechallenge. - Script the whole flow: create the subscription, wait for the challenge, call
/verify, then check the subscription's status withGET /developer/v1/webhooks/{webhook_.id}
How Hookdeck Can Help: Hookdeck records every request it receives, including the verification POST, so you can copy the challenge from the request in the Hookdeck dashboard and submit it yourself, whether or not your own service was running at the time.
The secret arrives with the challenge
The Problem: Your receiver rejects the verification request with a 401, and the subscription cannot be activated.
Why It Happens: The secret is in the response to the same API call that triggers the challenge, so a receiver that loads its secret at deploy time may not have it yet. Ramp counts a 4xx as an instant failure with no retry.
Workarounds:
- During setup, let a request whose
typeiswebhooks.verificationthrough to your logging even when the secret is not loaded yet. It carries nothing but the challenge. - Log the raw body of rejected requests while you set up a subscription, so a challenge is never lost.
How Hookdeck Can Help: Signature verification on Hookdeck's Ramp source type is optional. Create the subscription while verification is off, copy the challenge from the request log, then add the subscription secret to the source so every later delivery is checked.
4xx responses lose events
The Problem: An event that your handler rejected by mistake is never sent again.
Why It Happens: Ramp only retries 429, 5xx, timeouts and connection errors. A 3xx or any other 4xx is a final failure. A validation bug that returns 400, or an auth middleware that returns 403, silently drops events.
Workarounds:
- Return
401only for a failed signature check, and200for valid events you do not handle. - Return
5xx(or429) when your own dependencies are down, so Ramp retries. - Do not redirect the webhook URL.
How Hookdeck Can Help: Hookdeck accepts the event from Ramp first and then delivers it to your service with its own retry policy, so a bad response from your code becomes a retryable failure you can fix and replay, not a lost event.
Retries are short, and long outages lose data
The Problem: After an outage of more than a few minutes, some events are gone.
Why It Happens: Ramp makes up to 10 attempts with exponential backoff and full jitter, with the first retry 0 to 2 seconds later and later delays capped at 60 seconds. That covers a brief blip, not a deploy that goes wrong for an hour. Ramp's own troubleshooting notes say missed events during extended downtime may not be recoverable.
Workarounds:
- Keep the endpoint highly available and respond within 10 seconds.
- Run a periodic reconciliation job against the Ramp API for records that must not drift, such as transactions waiting to sync.
How Hookdeck Can Help: Hookdeck's ingestion stays up while your service is down and holds every event until your endpoint recovers, so Ramp's short retry window stops being the limit on how long you can be offline.
Thin payloads and out-of-order delivery
The Problem: A handler that trusts event order records a settled transaction as pending, or needs the amount and doesn't have it.
Why It Happens: Payloads carry only the resource ID, and Ramp warns that a retried transactions.authorized can arrive after transactions.cleared for the same transaction.
Workarounds:
- Treat each event as a signal to fetch the current resource from the API.
- Compare
created_atagainst what you have stored before overwriting state.
How Hookdeck Can Help: Hookdeck can set a maximum delivery rate for your service, so a burst of events turns into a steady stream of follow-up API calls rather than a spike.
Best practices
Verify against the raw body
Mount express.raw() on the route in Express, use await request.text() in a Next.js route handler, and await request.body() in FastAPI. Parse JSON only after the signature matches.
Deduplicate on the event ID
Ramp keeps the same id across all retries and repeats it in X-Ramp-Webhook-ID. Store it before processing and skip any event you have already seen. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Ramp times out after 10 seconds and treats that as a retryable failure. Verify, store the event, return 200, and fetch the resource from the API in a background job. See why to process webhooks asynchronously.
Keep test events away from production
POST /developer/v1/webhooks/mock-webhook-event sends a normally signed event to every matching active subscription for the token's business and integration, with no test marker. Ramp recommends a sandbox token and sandbox subscriptions for this. In production, live receivers process mock events as real activity.
Request scopes for the follow-up calls
The access token that creates a subscription needs the read scope for each event's resource. The integration that handles the event needs the same scope to fetch the resource afterwards, so request both at the same time.
Use business_id in multi-customer apps
If one receiver serves several Ramp businesses, route on business_id and keep a secret per subscription.
Conclusion
Ramp signs every webhook with a hex HMAC-SHA256 of the raw body in X-Ramp-Signature, keyed with the per-subscription secret. Record the webhooks.verification challenge and submit it to activate the subscription, verify against the raw bytes, deduplicate on the event id, and reserve 4xx responses for genuinely forged requests, because Ramp will not retry them.
Hookdeck Event Gateway sits in front of your receiver, keeps a log of the verification challenge, checks every X-Ramp-Signature, and holds events through outages far longer than Ramp's own retry window.
Get started with Hookdeck for free and handle Ramp webhooks reliably in minutes.