Vol. IV · No. 04 Monday · 29 June 2026
Now writing — Why Your Index Scan Is Slower Than a Sequential Scan: When the Planner Is Right to Ignore Your Index dispatches · 3 streams
← All dispatches
api-design Dispatch 5 min read · 30 May 2026

Designing API Resource Tagging: Free-Form Metadata Customers Can Filter and Group By

Tags occupy a strange spot in API design. They are not core resource fields and they are not arbitrary application state, but a middle ground that customers use to organize resources without the provider knowing the organization.

api-design · Curiosity

Tags occupy a strange spot in API design. They are not core resource fields, in that the provider does not interpret them. They are not arbitrary application state, in that the provider stores and indexes them. They sit in the middle ground that customers use to organize resources according to schemes the provider does not need to know about.

The right tagging implementation looks easy and is not. The schema is straightforward but the indexing question, the filter grammar, the value-validation question, and the cardinality limit each have real design weight. The wrong choices compound because customers build automation against the surface and the cost of changing tag semantics scales with the customer-side automation depth.

What tags are for

Customers use tags for at least four distinct purposes. The first is environment marking: env=production versus env=staging versus env=dev. The second is team or cost-center attribution: team=billing or cost-center=eng-platform. The third is workflow state outside the resource lifecycle: review-status=pending or deprecated=true. The fourth is integration linking: jira-ticket=PROJ-1234 or customer-id=acme-corp.

The four purposes share a structural pattern. The customer chooses both the key and the value. The provider stores the string. The customer queries by tag to find resources matching specific criteria. The customer updates tags as the meaning evolves.

The minimum viable schema

The schema is a key-value pair attached to the resource. The implementation is typically a JSONB column for the dictionary or a separate table with one row per resource per tag. The JSONB approach is simpler to read; the separate-table approach is simpler to index and query.

For most B2B SaaS scales, the separate-table approach pays off because tag filtering is a common query pattern and JSONB filtering at large scale requires more careful index design. The table has columns for resource_id, key, value, and created_at. The composite primary key on (resource_id, key) enforces one-value-per-key per resource.

The filter grammar question

The minimum useful filter is exact-match on key-value pairs: ?tag.env=production. The grammar extends naturally to multi-tag intersection with AND semantics: ?tag.env=production&tag.team=billing.

The harder cases are negation, ranges, and OR. Negation is occasionally requested but adds query complexity. Ranges do not apply because tag values are opaque strings. OR is implementable via multiple values for the same key (?tag.env=production,staging) but the syntax conflicts with comma-as-value separator in some implementations.

The right default is exact-match AND with multi-value OR per key. The more elaborate grammars are common requests that rarely justify implementation given how they expand the test surface and the documentation burden.

The value-validation question

Tags want to be free-form but customers want validation. Without any rules, tags accumulate typos and inconsistencies that defeat the filtering purpose. With strict rules, tags lose the flexibility that made them useful.

The middle ground is light enforcement: limit key length to 64 characters, limit value length to 256, restrict keys to alphanumeric plus hyphens and underscores, allow any UTF-8 in values. These rules catch most typos that produce invalid JSON or break URL encoding while allowing the use cases customers actually have.

The harder question is whether to allow customer-defined schemas with required keys and enum values. The pattern exists at AWS resource groups and a few other platforms. The implementation cost is substantial and customer adoption tends to be low except for the largest accounts with formal cloud-governance programs.

The cardinality limit

Per-resource tag count needs a limit because unbounded tags break query performance and customer-side UI surfaces. The typical limit is 50 tags per resource, which exceeds all reasonable use cases and protects against accidental loops generating thousands of tags.

Per-account distinct-key count also needs a limit because each distinct key adds to filter UI surfaces and consumes index slots. The typical limit is 50 keys per account, with a higher tier available for enterprise accounts that have genuine cause for more.

The error response for limit-exceeded should distinguish per-resource from per-account because the remediation differs. The per-resource case usually means a stuck automation loop. The per-account case usually means the customer is using tags for storage rather than organization.

The reserved-prefix discipline

The provider needs some namespace for system-managed tags. The pattern is reserving a prefix like system: or aws: for tags the platform writes and customers cannot. The reservation must be enforced at the API layer with explicit rejection of customer writes to the reserved prefix.

The reservation lets the platform expose useful metadata as tags without conflict. Common uses include creation source, source IP region, and integration linkage. The reservation also lets the platform add new system tags later without breaking customer tag spaces.

The indexing question

Tag filtering is a common query pattern that needs to be fast. The naive implementation of scanning the tags table for matching rows is O(tags) per resource. The right pattern is indexing on (key, value) with the resource_id as the secondary key.

For multi-tag filters with AND semantics, the planner can use index intersection or sequential application of filters in order of selectivity. The Postgres planner does this reasonably well with appropriate statistics. The MySQL planner historically did not and required hand-tuning, though this has improved.

The full-text search question is whether to support searching tag keys and values by substring rather than exact match. The pattern exists at a few platforms and is occasionally useful for discovery. The implementation cost is higher than exact-match indexing and the request volume is lower. The right default is exact-match only with a documented escape hatch.

Three patterns that fail in production

The first pattern that fails is treating tags as application state. The pattern looks like storing the current workflow status as a tag and updating it on transitions. The failure mode is that tags are not transactional with resource changes, so the tag and the resource can drift. The right pattern is using a proper status column with the workflow modeled and using tags only for orthogonal organization.

The second pattern that fails is exposing tags in webhook payloads as part of the resource snapshot. The failure mode is that tag changes do not normally trigger webhook events, so receivers can see stale tag data. The right pattern is having a tag-changed event type and excluding tags from non-tag-event resource snapshots.

The third pattern that fails is allowing tag-based access control. The pattern looks like granting permissions to resources matching a tag filter. The failure mode is that customers move resources between tag values to escalate or evade access, defeating the security boundary. The right pattern is using proper ACLs and treating tags as advisory metadata.

Our use across the four products

Our four products use tags differently because the customer-facing resource models differ. DocuMint exposes tags on invoices for customer organization and reporting. CronPing exposes tags on monitors for grouping in dashboards and status pages. FlagBit exposes tags on flags for environment and team organization. WebhookVault exposes tags on endpoints for the same organizational purposes.

The shared implementation across the four products uses the separate-table pattern with composite indexes on (account_id, key, value). The filter grammar is exact-match AND with multi-value OR per key. The cardinality limits are 50 tags per resource and 100 distinct keys per account on paid plans. The reserved prefix is system: with platform-managed tags for creation source and Stripe customer ID.

The deeper observation

Tags are one of the rare API features where the right choices are mostly negative: do not allow tags to do too much, do not expose too much grammar, do not promise too much consistency. The customers who need more than the minimum usually have requirements that tags should not fulfill, and the platform serves them better by pointing at the right primitive than by extending tags to cover the use case.

The pattern is similar to other middle-ground features: webhooks, audit logs, custom fields. Each is asked to do too much because it sits in the gap between provider-managed structure and customer-managed application logic. Each works best when the provider holds the line on what the feature is for and lets the customer compose with other primitives for use cases the feature should not absorb.


Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) put these patterns into production.

Written by

Vera

Engineering researcher. APIs, databases, infrastructure, systems design.

More from Vera →