How to Track Per-Customer and Per-Project API Usage with Tag Parameter
If you use DataForSEO for building an SEO tool or powering an agency workflow, you almost certainly have a single DataForSEO account serving more than one client. While a single SERP API call costs a fraction of a cent, the total adds up across thousands of keywords, locations, and daily runs. When everything is billed to one account, the following question appears very quickly: “How much of this month’s spend belongs to Client A, and how much to Client B?”
Before answering that there is no way to accurately bill your clients, sport a runaway job that’s burning a hole through your budget, and tell which project is profitable in the long run. The problem is not that the data doesn’t exist. Every task has a cost clearly displayed in the API response and DataForSEO dashboard. The problem is that all the tasks are billed to one account balance as a single team. However, there’s a simple reason for this. DataForSEO was designed around the account as the billing unit: one login, one balance, one invoice.That keeps pricing simple and predictable.
The good news is every task-based and live POST endpoint in DataForSEO accepts an optional tag parameter, which is a free-form label you set yourself. It’s the single built-in mechanism for saying “this task belongs to project X.” DataForSEO stores that label, echoes it back in every response, and passes it straight through to your pingback and postback callbacks.
Note that the tag parameter gives you the label, not the report. DataForSEO does not hand you a per-project spend breakdown. Instead, the tag is the thread you pull through the entire task lifecycle, and the per-project numbers are something you assemble on your own side, by capturing each task’s tag together with its cost as responses come back – and then grouping in your own database.
What the tag parameter is
The tag parameter is an optional, user-defined string you include in the body of any DataForSEO POST request. You choose its value, validate its meaning, or enforce any format. It exists for one purpose: to let you label a task so you can identify it later.
Here are the rules:
- Required? No, it’s optional on every endpoint.
- Type. String (integers are accepted and converted to strings; arrays and objects are rejected).
- Length. Up to 255 characters.
- Characters. Any characters, any encoding, including letters, digits, symbols, unicode, emoji are all accepted.
- Unique? No, and that’s the point. Many tasks can share the same tag. Tags are meant to group tasks, not identify a single one.
- Which endpoints? All of them. Every Task POST endpoint across every DataForSEO API accepts the tag parameter.
Once set, the tag does three things:
- It’s echoed back to you in the data object of every response – whether that’s the initial Task POST response, a later Task GET, a live response, or a Tasks Ready listing. You always get your tag back.
- It’s stored with the task record, so it persists through the task’s entire lifecycle and is recoverable at any point.
- It’s passed through to your callbacks. If you configure a
pingback_urlorpostback_url, you can embed the$tagvariable in the URL and DataForSEO substitutes the actual tag value before sending the callback.
What you can and can’t do with a tag parameter
Before building anything, it’s worth being explicit about what tag gives you and what it doesn’t.
| You can… | You cannot… |
|---|---|
| Attach a free-form label (up to 255 characters) to any task | Get a per-tag cost or usage breakdown in the DataForSEO dashboard |
| See the tag echoed back in every response’s data object | Filter or list tasks by tag through any API endpoint |
| Receive the tag in your pingback ($tag variable) and postback (data.tag) | Set per-tag spend limits or per-tag billing |
| Capture the tag and per-task cost from responses and build your own per-project reports | Query DataForSEO later for “all tasks tagged X”, the tag is not a lookup key |
| Pack multiple dimensions into one tag using a delimiter | Use the tag as a task identifier – use the task id for that |
Note: DataForSEO stores your tag alongside the task, but the account dashboard and analytics views do not aggregate by it. The reliable way to track per-project usage is to log the tag and cost yourself at the moment you receive each response or callback. This is by design: tag is a labelling and routing tool, and the aggregation is intentionally left to your system so you can slice it however your business needs.
The tracking architecture
Regardless of which DataForSEO APIs you use or whether you work synchronously or asynchronously, the recommended pattern is always the same five steps:
- Decide a tag naming convention that encodes the dimensions you care about (customer, project, campaign, job type).
- Set every task with a tag that follows that convention.
- Capture, for each task: the
id, thetag, and thecostfrom the response (for synchronous/live calls) or from the callback and/or task results (for asynchronous pingback/postback flows). - Store these records in your own database, keyed by task id to avoid double-counting.
- Aggregate data per tag (or per segment within the tag) using simple SQL.
Step 1. Choose a tag naming system
You only get one tag field per task, but you likely want to track more than one dimension (e.g., which customer, which project, which campaign). The easy solution is to write multiple dimensions into a single string using a delimiter, and then split them apart when querying.
Pick a delimiter that won’t appear in your dimension values. The pipe character (`), colon (:), or tilde (~) will all work well. Avoid commas and spaces if the tag will flow into a callback URL.
Recommended pattern: customer:project:campaign:job_type
Example: seo-corp:seo-tool-1:2026q3:rank-check
Rules we recommend following:
- Keep it ASCII and avoid spaces if the tag will appear in a callback URL. This makes URL decoding on your side easier.
- Stay under 255 characters total. That’s the limit, measured after trimming whitespace.
- Be consistent. Pick one delimiter and one dimension order, and stick with it across all your tasks. Mixing conventions makes aggregation queries messy.
- Include only the dimensions that you’ll actually report on. Every extra segment makes your SPLIT_PART queries cleaner.
- Keep tags your stable. Changing a naming convention mid-month makes it hard to reconcile your spending in the long run.
Step 2. Set API tasks with tags
Setting a tag is as simple as adding a "tag" field to your POST request. One of the most useful things about tags is that a single batch can contain tasks for different projects, each tagged differently. This means you don’t need to split your API calls by client. You can send one POST with 100 tasks spanning 10 projects, with each task carrying its own label.
cURL request example for Google SERP API
login="login"
password="password"
cred="$(printf ${login}:${password} | base64)"
curl --location --request POST 'https://api.dataforseo.com/v3/serp/google/organic/task_post' \
--header "Authorization: Basic ${cred}" \
--header "Content-Type: application/json" \
--data-raw '[
{
"keyword": "rank tracker",
"location_code": 2840,
"language_code": "en",
"tag": "seo-corp:seo-tool-1:2026q3:rank-check"
},
{
"keyword": "site audit tool",
"location_code": 2840,
"language_code": "en",
"tag": "agency101:report-july:2026q3:rank-check"
}
]'
For each task in the batch, you’ll get back the following fields:
id— the unique task identifier (used later to fetch results);tag— the tag you set, echoed back in the data object;cost— the cost of that individual task in USD.
The per-task cost is returned immediately in the Task POST’s immediate response, so even the simplest tracking workflow needs no extra API call. You can start logging the expenses the moment tasks are created.
Step 3. Capture tag and cost
How you capture the tag and cost depends on how you interact with DataForSEO and your use case. There are three methods, each suited to a different workflow, but you can also combine them to fit your unique use case.
Method 1: Synchronous capture (the simplest).
This method is best for task tracking when you don’t need results tied to the log, or when you’re using live endpoints.
When you send a task POST (or a live request), the response immediately contains the task id, your tag, and the cost. You capture all three straight from the response and save them to your database.
# Right after the POST from Step 2
import sqlite3
from datetime import datetime
db = sqlite3.connect("usage.db")
db.execute("""
CREATE TABLE IF NOT EXISTS task_log (
task_id TEXT PRIMARY KEY,
tag TEXT,
cost REAL,
status INTEGER,
endpoint TEXT,
recorded_at TEXT
)
""")
for task in response["tasks"]:
db.execute(
"INSERT OR IGNORE INTO task_log VALUES (?, ?, ?, ?, ?, ?)",
(task["id"], task["data"]["tag"], task["cost"],
task["status_code"], "serp/google/organic/task_post",
datetime.utcnow().isoformat())
)
db.commit()
Note that for most tasks, the cost shown at creation time is final. However, for tasks where the charge depends on what the search engine actually returns (for example, when you set a depth above 10 and the SERP contains more than 10 results, or when you enable asynchronous AI Overview loading) the final cost appears in the completed Task GET or the postback response. For precise tracking of expenses on those features, use the methods described below.
Method 2: Asynchronous with pingback.
Best for async task-based workflows where you want to be notified when tasks finish, then fetch results and the final cost.
With pingbacks, DataForSEO sends a lightweight GET request to your URL when a task completes. The URL can include the $id and $tag variables, which are substituted with the actual values before sending.
Learn more about pingbacks in DataForSEO APIs.
Set a task with a pingback URL:
post_data = [dict(
keyword="rank tracker",
location_code=2840,
language_code="en",
tag="seo-corp:seo-tool-1:2026q3:rank-check",
pingback_url="https://your-server.com/api/dfs-pingback?id=$id&tag=$tag"
)]
response = client.post("/v3/serp/google/organic/task_post", post_data)
Your example pingback receiver (the Flask framework example):
from flask import Flask, request
app = Flask(__name__)
@app.get("/api/dfs-pingback")
def dfs_pingback():
task_id = request.args.get("id")
tag = request.args.get("tag") # already URL-decoded by Flask
# Fetch the completed task to get results + the authoritative cost
result = client.get(f"/v3/serp/google/organic/task_get/advanced/{task_id}")
for task in result["tasks"]:
save_to_db(
task_id = task["id"],
tag = task["data"]["tag"],
cost = task["cost"], # final cost
status = task["status_code"]
)
return "ok", 200 # MUST respond within 10 seconds
The pingback itself is just a notification – it carries the id and tag but not the results or the final cost. Your receiver makes one Task GET call to retrieve everything. This means the tag arrives twice (once in the pingback URL, and once in the tag field of the Task GET response), providing you with a necessary cross-check.
Method 3: Asynchronous with postback.
Best for asynchronous workflows where you want maximum efficiency. The results and final cost are delivered to you in a single push, no extra API calls needed.
With postbacks, DataForSEO sends a POST request to your URL containing the full result JSON (gzip-compressed). The body already includes the tag and cost fields for each task. This is the most efficient pattern because you get everything in a single inbound request.
Learn more about postbacks in DataForSEO APIs.
Set the task with a postback_url:
post_data = [dict(
keyword="rank tracker",
location_code=2840,
language_code="en",
tag="agency101:report-july:2026q3:rank-check",
postback_data="advanced", # required when postback_url is set
# options: "regular", "advanced", "html"
postback_url="https://your-server.com/api/dfs-postback"
)]
response = client.post("/v3/serp/google/organic/task_post", post_data)
Your postback receiver (Flask example):
import gzip, json
from flask import Flask, request
app = Flask(__name__)
@app.post("/api/dfs-postback")
def dfs_postback():
raw = request.get_data()
payload = json.loads(gzip.decompress(raw)) # body is gzip-compressed
for task in payload["tasks"]:
save_to_db(
task_id = task["id"],
tag = task["data"]["tag"], # your tag
cost = task["cost"], # final cost for this task
status = task["status_code"]
)
return "ok", 200
Step 4. Aggregate usage per tag in your database
Once you’re logging (id, tag, cost) for every task, the per-project reports you need are straightforward SQL queries against your own table.
Let’s assume a log table called task_log with columns: task_id, tag, cost, status, endpoint, recorded_at. Using the customer:project:campaign:job_type convention, let’s extract the first segment to group by customer:
-- MySQL
SELECT
SUBSTRING_INDEX(tag, ':', 1) AS customer,
COUNT(*) AS tasks,
ROUND(SUM(cost), 4) AS spend_usd,
MAX(recorded_at) AS last_task_at
FROM task_log
WHERE recorded_at >= '2026-08-01'
AND recorded_at < '2026-09-01'
GROUP BY SUBSTRING_INDEX(tag, ':', 1)
ORDER BY spend_usd DESC;
Result example:
| customer | tasks | spend_usd | last_task_at |
|---|---|---|---|
| seo-corp | 2,847 | 4.2705 | 2026-08-20T11:32:01 |
| agency101 | 1,203 | 3.6090 | 2026-08-20T14:05:33 |
You can apply the same principle to break your API tasks down by campaign name, or essentially any other segment you choose to include in your tags.
Best Practises
- One field, multiple dimensions. Pack them with a delimiter and split in SQL. This is the single most useful technique for getting rich reporting out of one parameter.
- Log the endpoint too. Storing which DataForSEO API produced each task lets you split spend by API type as well as by project.
- Key your log table on task id. Pingbacks and postbacks can occasionally be sent more than once. Using task
idas your primary key with an idempotent insert prevents double-counting. Learn more about the ID List endpoint. - Record at creation and update at completion. If you need both early spend visibility and final-cost accuracy, insert a record with the creation cost immediately, then update it with the final cost when the pingback or postback arrives.
- Keep tags short and stable. Changing a naming convention mid-month makes it hard to reconcile spend across the boundary. If you must change conventions, don’t forget to document the switch.
- Validate tags on your side before sending. Since DataForSEO accepts almost any string up to 255 characters, a bug in your tagging code (an empty tag, a missing segment, a wrong delimiter) will produce tasks that will be hard to aggregate.