Google Ads Conversions
This guide covers how to submit offline conversions to Google Ads through the TNT Growth API, check submission status, and investigate per-conversion results.
Base URL
All endpoints are mounted under the /api prefix on the Flex host:
https://flex.tntgrowth.io/api/...
Authentication
All requests require an x-api-key header with your client-specific API key provided by TNT Growth.
-H "x-api-key: YOUR_API_KEY"
The API key is scoped to a single client (= one Google Ads customer ID). All submission and lookup endpoints enforce that the customer_id in the payload matches the Google Ads customer ID linked to your API key. A mismatch returns 403 Forbidden.
Submitting Conversions
Endpoint
POST /api/google/conversions/add
Request
Send an array of conversion objects. Each request accepts a maximum of 100 conversions. For larger batches, chunk your data into groups of 100 and submit multiple requests. Exceeding 100 conversions returns a 413 Payload Too Large error.
Request Body Schema
| Field | Type | Required | Description |
|---|---|---|---|
customerId | string | Yes | Google Ads customer ID (no dashes) |
conversionActionId | string | Yes | The conversion action ID from Google Ads |
timeStamp | number | Yes | Unix timestamp (seconds) of when the conversion occurred |
gclid | string | No* | The Google Click ID captured from the ad click |
gbraid | string | No* | Google Brand Retail Ads ID (iOS privacy-safe alternative to gclid) |
wbraid | string | No* | Web Retail Ads ID (web privacy-safe alternative to gclid) |
firstTouchUrl | string | No | Landing page URL — gbraid/wbraid are auto-extracted from query params if not provided directly |
conversionValue | number | No | Monetary value of the conversion. Defaults to 0 if omitted |
email | string | No | User email for enhanced conversions (see Enhanced Conversions) |
phone | string | No | User phone for enhanced conversions (see Enhanced Conversions) |
utmSource | string | No | UTM source parameter (e.g. google) — stored for attribution reporting |
utmMedium | string | No | UTM medium parameter (e.g. cpc) — stored for attribution reporting |
utmCampaign | string | No | UTM campaign name — stored for attribution reporting |
utmTerm | string | No | UTM search keyword — stored for attribution reporting |
utmContent | string | No | UTM content parameter (ad/creative variant) — stored for attribution reporting |
* Click identifier requirement: You must provide at least one of gclid, gbraid, or wbraid — OR — provide email and/or phone for enhanced conversion matching. If all identifiers are omitted, the conversion will fail with MISSING_IDENTIFIER_ERROR.
Priority: When multiple click identifiers are provided, the system uses gclid first, then gbraid, then wbraid.
Example Request
curl -X POST https://flex.tntgrowth.io/api/google/conversions/add \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '[
{
"customerId": "4482546575",
"conversionActionId": "7567171084",
"gclid": "Cj0KCQjwh-HPBhCIARIsAC0p3ce...",
"timeStamp": 1778043599,
"conversionValue": 1,
"utmSource": "google",
"utmMedium": "cpc",
"utmCampaign": "brand-search-q2",
"utmTerm": "best treatment center",
"utmContent": "headline-variant-a"
},
{
"customerId": "4482546575",
"conversionActionId": "7567171084",
"gclid": "EAIaIQobChMI4Yb...",
"timeStamp": 1778043610,
"conversionValue": 50,
"email": "user@example.com",
"phone": "+14155552671",
"utmSource": "google",
"utmMedium": "cpc",
"utmCampaign": "competitor-keywords"
}
]'
Example Response
{
"message": "Conversions queued successfully",
"submissionIds": [
"8f3c2d10-7e6b-4a51-9d8c-3b2e1f0a9b8c"
],
"statusUrls": [
"/api/conversions/submissions/8f3c2d10-7e6b-4a51-9d8c-3b2e1f0a9b8c"
],
"batchStatusUrl": "/api/google/conversions/status"
}
| Field | Description |
|---|---|
submissionIds | One UUID per (customerId, clientId) group in the batch — poll each one independently |
statusUrls | Convenience — pre-built URLs for the submission status endpoint |
batchStatusUrl | The shared batch-lookup URL for per-conversion status |
deduped | (Optional) Count of conversions the 24-hour Redis dedup rejected as duplicates of a recent submission. Not an error — surfaced so callers can distinguish "Google accepted this" from "silently skipped because we already sent this payload". |
Response Codes
| Status | When |
|---|---|
200 | All conversions queued (or successfully deferred — see Deferred Delivery) |
207 | Multi-status — submission accepted, but one or more conversions hit a pre-Google validation error. Inspect data for the per-row errors, or follow the two-call pattern in Inspecting Failed Conversions In Your Batch. |
413 | More than 100 conversions in one request — chunk and retry |
500 | Internal error — submissionIds still surfaces any partial commits |
Enhanced Conversions (Email & Phone)
Enhanced conversions allow Google Ads to match conversions even when a click identifier (gclid/gbraid/wbraid) is missing or belongs to a different account. Useful for:
- Conversions where the GCLID was lost or not captured
- Cross-account click matching (GCLID from a different MCC hierarchy)
- Improving match rates alongside click identifiers
How Hashing Works
Google Ads requires user identifiers to be SHA-256 hashed before upload. You have two options:
Option 1: Send raw values (recommended)
Send plain email and phone — the system normalizes and hashes them:
- Email: lowercased and trimmed before hashing
- Phone: formatted to E.164 international standard (e.g.
+14155552671) before hashing
{
"email": "User@Example.com",
"phone": "415-555-2671"
}
Option 2: Send pre-hashed values
If you already have SHA-256 hashes, send them directly. The system auto-detects hashed values (64-character hex strings) and uses them as-is:
{
"email": "a1b2c3d4e5f6...64_char_sha256_hash",
"phone": "f6e5d4c3b2a1...64_char_sha256_hash"
}
When in doubt, send raw values and let the system handle normalization and hashing. This avoids common issues with incorrect hashing (e.g. hashing before lowercasing the email).
Requirements
Enhanced conversions must be enabled in your Google Ads account:
- Go to Google Ads > Goals > Conversions > Settings
- Enable Enhanced conversions for leads
If not enabled, conversions with only email/phone identifiers will fail with an "enhanced conversions not enabled" error.
Batching
The API accepts an array of conversion objects, up to 100 per request.
| Scenario | Recommendation |
|---|---|
| 1-100 conversions | Send in a single request |
| 101-1000 conversions | Chunk into groups of 100, send sequentially |
| 1000+ conversions | Chunk into groups of 100 with a 1-2 second delay between requests |
const chunkSize = 100;
for (let i = 0; i < conversions.length; i += chunkSize) {
const batch = conversions.slice(i, i + chunkSize);
await fetch('/api/google/conversions/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': API_KEY },
body: JSON.stringify(batch),
});
}
Deferred Delivery (Future Timestamps)
When a conversion's timeStamp is in the future (e.g. an appointment scheduled for next week), the system defers delivery instead of rejecting the conversion. A delayed job is enqueued and fires automatically after the timestamp passes — Google Ads only accepts past-dated conversion times, so deferral is the only path that lands the data.
| Timestamp class | Behavior |
|---|---|
| Past (or within clock-skew of now) | Normal upload — uses the immediate queue |
| Future, within 30 days | Deferred — scheduled to fire after timeStamp |
| Future, more than 30 days out | Rejected as BAD_TIMESTAMP_OR_TOO_FAR_FUTURE |
The 30-day ceiling (MAX_DEFERRAL_DAYS) protects against units-confusion bugs (milliseconds-as-seconds typically lands the timestamp years in the future).
How the response surfaces deferral
POST /api/google/conversions/add— returns 200 as usual; the submission row carriesscheduledForand its status becomespending_delivery(see Status Values) until the deferred job fires.- TNT-integrated webhook ingest paths (Calendly, CallTracking Metrics) — return
202 Acceptedwith asubmissionIdyou can poll. The 202 is a signal that the conversion was accepted but is not yet on its way to Google Ads.
HTTP/1.1 202 Accepted
{
"message": "Deferred CTM conversion for Acme until 2026-06-04T15:30:00.000Z (submission 8f3c2d10-7e6b-4a51-9d8c-3b2e1f0a9b8c)",
"data": {
"submissionId": "8f3c2d10-7e6b-4a51-9d8c-3b2e1f0a9b8c"
}
}
Either way, poll GET /api/conversions/submissions/:id until status transitions away from pending_delivery to confirm Google Ads accepted the conversion.
Checking Submission Status
After submitting conversions, poll the submission status to determine when processing is complete.
Endpoint
GET /api/conversions/submissions/:submissionId
Path Parameters
| Parameter | Type | Description |
|---|---|---|
submissionId | string | The UUID returned by the submit response |
Example Request
curl https://flex.tntgrowth.io/api/conversions/submissions/8f3c2d10-7e6b-4a51-9d8c-3b2e1f0a9b8c \
-H "x-api-key: YOUR_API_KEY"
Example Response
{
"id": "8f3c2d10-7e6b-4a51-9d8c-3b2e1f0a9b8c",
"source": "GOOGLE_ADS",
"externalAccountId": "4482546575",
"expectedCount": 2,
"createdAt": "2026-05-11T12:00:00.000Z",
"scheduledFor": null,
"counts": {
"success": 2,
"failed": 0
},
"status": "complete"
}
Response Fields
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Submission ID |
source | string | Conversion platform — GOOGLE_ADS for this endpoint |
externalAccountId | string | Google Ads customer ID this submission was scoped to |
expectedCount | number | Number of conversion rows in the submission |
createdAt | string (ISO-8601) | When the submission was created |
scheduledFor | string (ISO-8601) | null | Present and non-null when the submission was deferred for future delivery — the ISO timestamp at which the queued job will fire (see Deferred Delivery) |
counts.success | number | Conversions Google Ads accepted |
counts.failed | number | Conversions that failed pre- or post-upload |
status | string | One of the Status Values below |
Status Values
| Status | Description |
|---|---|
pending | Conversions are queued and waiting to be processed |
pending_delivery | The submission has a future-dated timeStamp. The system has scheduled it for delivery and will send to Google Ads automatically after the timestamp passes. The scheduledFor field carries the ISO-8601 timestamp at which the conversion is queued to fire. |
complete | All conversions processed successfully |
complete_with_errors | Processing finished but some conversions failed — use Inspecting Failed Conversions to fetch the actual error messages |
stalled | Processing has not progressed in 15 minutes — contact support |
Recommended Polling Pattern
- Wait 30-60 seconds after submission before first poll.
- Continue polling until the response carries a terminal status —
completeorcomplete_with_errors.pendingandpending_deliveryare both non-terminal and must not end polling. - For
pending, poll every 5-10 seconds. - For
pending_delivery, thescheduledFortimestamp tells you when delivery is queued to fire — there is no value in polling faster than that. Resume the 5-10 second cadence oncescheduledForhas passed, since the deferred worker transitions the submission tocomplete/complete_with_errorsshortly after. - When the status becomes
complete_with_errors, call the failed-conversions endpoint with?submissionId=...to fetch the actual errors. stalledmeans processing hasn't progressed in 15 minutes — stop polling and contact support.
Inspecting Failed Conversions In Your Batch
When a submission ends with complete_with_errors, the submission status endpoint surfaces only the count of failed rows. To fetch the actual error messages for the rows that failed, use the per-platform failed-conversions endpoint with ?submissionId=<id>.
Endpoint
GET /api/conversions/google/failed?submissionId=<submissionId>
Query Parameters
| Parameter | Type | Description |
|---|---|---|
submissionId | string (UUID) | Filter to a single submission's failed rows |
errorCategory | string | Optional — filter by category label (see Error Categories) |
limit | number | Page size (1-1000, default 50) |
offset | number | Page offset (default 0) |
Example Request
curl "https://flex.tntgrowth.io/api/conversions/google/failed?submissionId=8f3c2d10-7e6b-4a51-9d8c-3b2e1f0a9b8c" \
-H "x-api-key: YOUR_API_KEY"
Example Response
{
"message": "Retrieved 1 failed google conversions",
"data": {
"data": [
{
"id": 81923,
"errorMessage": "The click occurred outside of the conversion action's click-through window (90 days)",
"errorCategory": "Click-through window expired",
"clientId": "client_abc123",
"clientName": "Acme",
"platformId": "4482546575",
"createdAt": "2026-05-11T12:00:33.000Z",
"ingestionSource": "API",
"reprocessedAt": null,
"request": {
"customerId": "4482546575",
"conversionActionId": "7567171084",
"gclid": "Cj0KCQjwh-HPBhCIARIsAC0p3ce...",
"timeStamp": "1772044999",
"conversionValue": 1
}
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0
}
}
}
Recommended Two-Call Pattern
1. POST /api/google/conversions/add → { submissionIds: [id] }
2. GET /api/conversions/submissions/:id → poll until status !== 'pending'
3. If status === 'complete_with_errors':
GET /api/conversions/google/failed?submissionId=:id → per-row error details
Error Categories
Each failed-conversion row is tagged with an errorCategory label so callers can group by failure class without parsing the raw errorMessage. The same label can be passed back as the errorCategory query parameter to filter the list.
| Category label | Triggered by errorMessage containing |
|---|---|
Click-through window expired | click-through window |
Timestamp precedes click | precedes the click |
Click not found | click_not_found |
Not authorized | not authorized |
Action not found | conversion action not found |
Enhanced conversions config | enhanced conversions |
Future timestamp | after allowed maximum |
Other | Anything that doesn't match the patterns above |
Detailed Per-Conversion Status (Batch Lookup)
For granular visibility into individual conversion results, use the batch status endpoint. This is the right call when you need to confirm whether a specific (customer_id, gclid, conversion_action_id) triple landed — for example, reconciling a CRM export against the data you submitted weeks earlier.
Endpoint
POST /api/google/conversions/status
Request
The request body is a JSON object with a single key conversions whose value is an array of lookup tuples. Each request accepts a maximum of 100 conversions.
The status endpoint expects { "conversions": [...] } — not a bare array. Submitting a bare array returns 400 Bad Request at the schema-validation layer.
The status endpoint uses snake_case field names (customer_id, conversion_action_id), while the submission endpoint uses camelCase (customerId, conversionActionId). This is intentional and not a typo.
Request Body Schema
| Field | Type | Required | Description |
|---|---|---|---|
conversions | array | Yes | 1-100 lookup tuples |
conversions[].customer_id | string | Yes | Google Ads customer ID (no dashes) — must match the API key's client |
conversions[].gclid | string | Yes | The Google Click ID submitted with the original conversion |
conversions[].conversion_action_id | string | Yes | The conversion action ID submitted with the original conversion |
Example Request
curl -X POST https://flex.tntgrowth.io/api/google/conversions/status \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{
"conversions": [
{
"customer_id": "4482546575",
"gclid": "Cj0KCQjwh-HPBhCIARIsAC0p3ce...",
"conversion_action_id": "7567171084"
},
{
"customer_id": "4482546575",
"gclid": "EAIaIQobChMI4Yb...",
"conversion_action_id": "7567171084"
}
]
}'
Example Response
{
"results": [
{
"customer_id": "4482546575",
"gclid": "Cj0KCQjwh-HPBhCIARIsAC0p3ce...",
"conversion_action_id": "7567171084",
"status": "processed",
"recorded_at": "2026-04-09T17:33:21.043Z"
},
{
"customer_id": "4482546575",
"gclid": "EAIaIQobChMI4Yb...",
"conversion_action_id": "7567171084",
"status": "partial_failure",
"message": "The click occurred outside of the conversion action's click-through window",
"recorded_at": "2026-04-09T17:33:21.043Z"
}
]
}
Per-Conversion Status Values
| Status | Description |
|---|---|
processed | Conversion uploaded successfully to Google Ads |
pending | Conversion has been claimed for upload but Google has not responded yet |
partial_failure | Google accepted the request but reported an error for this conversion |
failed_pre_google | Conversion failed validation before reaching Google Ads (e.g. click-through window pre-filter) |
not_found | No matching conversion record found in the system |
Per-Result Fields
| Field | Type | Notes |
|---|---|---|
customer_id | string | Echoed from the request tuple |
gclid | string | Echoed from the request tuple |
conversion_action_id | string | Echoed from the request tuple |
status | string | See enum above |
message | string | (Optional) Specific error detail when status is not processed. Omitted for processed and not_found. |
recorded_at | string (ISO-8601) | (Optional) When the conversion (or failure) was recorded |
Response Codes
| Status | When |
|---|---|
200 | Lookup completed (results reflects per-row status — including not_found) |
400 | Bad request — most commonly a missing conversions envelope or a malformed tuple |
401 | Missing or invalid x-api-key |
403 | One or more customer_id values don't match the Google Ads customer linked to the API key, or the client has no Google Ads customer configured |
500 | Internal error during lookup |
Common Errors & Troubleshooting
When a conversion fails with partial_failure, the message field contains the specific error from Google Ads. Here are the most common errors and how to resolve them.
Click-Through Window Expired
Error: The click occurred outside of the conversion action's click-through window
The time between the ad click and the conversion exceeds the conversion action's configured window (typically 30-90 days).
Conversions older than the Google Ads conversion action's click_through_lookback_window_days are pre-filtered before upload — they are persisted as failed_pre_google rows with the conversion age and the configured window in the errorMessage, instead of going through the retry loop. This makes the failure visible immediately on /status and /failed without waiting for Google to reject.
Fix: Check your CRM data freshness. If conversions are consistently arriving late, extend the click-through window in Google Ads > Goals > Conversion Action > Settings.
Future Timestamp
Error: Date is after allowed maximum
The conversion timestamp is more than 30 days in the future (beyond MAX_DEFERRAL_DAYS). Timestamps within 30 days of the future are deferred automatically — see Deferred Delivery.
Fix: Verify the timestamp units (seconds, not milliseconds). The most common cause is milliseconds-as-seconds, which lands the timestamp ~50 years in the future.
Click Not Found
Error: The click ID could not be matched to an existing Google Ads click
The GCLID doesn't match any click in the Google Ads account.
Fix:
- Verify the GCLID is correctly captured (no truncation or encoding issues)
- Confirm the GCLID belongs to the correct Google Ads account
- If the GCLID is from a different account, provide
email/phonefor enhanced conversion fallback
Conversion Action Not Found
Error: Conversion action not found
The conversionActionId doesn't exist in the specified Google Ads account.
Fix: Verify the conversion action ID is correct and that the API has access to the account.
Not Authorized (Foreign GCLID)
Error: Not authorized to access the customer account
The GCLID belongs to a Google Ads account outside your MCC hierarchy.
Fix: Provide email and/or phone so the system can use enhanced conversion matching instead of the GCLID.
Enhanced Conversions Not Enabled
Error: Enhanced conversions settings are not configured
The Google Ads account doesn't have Enhanced Conversions for Leads enabled.
Fix: Enable it in Google Ads > Goals > Conversions > Settings > Enhanced conversions for leads.
Timestamp Precedes Click
Error: The conversion timestamp precedes the click timestamp
The conversion is recorded as happening before the user clicked the ad.
Fix: Ensure you're using the actual conversion timestamp, not the click time or an incorrect date.
Timezone Handling
The system automatically adjusts conversion timestamps to match your Google Ads account timezone. If your conversion timestamp doesn't align with the account's timezone (e.g. you send UTC but the account is in Eastern Time), the system will retry with timezone offsets to find a match.
You don't need to do anything — timezone conversion is automatic. Just ensure your timestamps are accurate relative to when the conversion actually occurred.
Best Practices
- Chunk large batches into groups of 100 conversions per request
- Store submission IDs from responses for status tracking
- Poll with a delay — wait 30-60 seconds after submission before checking status, as conversions are processed asynchronously
- Treat
pending_deliveryas expected for any conversion submitted with a future timestamp —scheduledFortells you exactly when delivery is queued to fire - Use the two-call pattern for errors — when a submission reports
complete_with_errors, call/api/conversions/google/failed?submissionId=...to get the actual error messages for the failed rows - Use batch status for spot-checks —
/api/google/conversions/statusis the right call when you have a list of(customer_id, gclid, conversion_action_id)triples and want to confirm whether each landed - Provide click identifiers when possible —
gclidgives the best match rate, followed bygbraid/wbraid - Include email/phone as fallback — even when you have a GCLID, providing email or phone improves match rates and handles foreign-account GCLIDs
- Send raw values for hashing — let the system normalize and hash email/phone rather than hashing yourself, to avoid common normalization mistakes
- Validate GCLIDs — ensure they are not truncated, double-encoded, or from a different account
- Check conversion action IDs — verify they exist in the target Google Ads account before submitting