On this page

Working with DataSync data

Every DataSync resource, entities, relationships, users, channels, and memberships, supports the same operation set:

  • list
  • get
  • create
  • replace
  • partial update
  • delete

All of these operations require Access Manager authorization. Refer to Access control for how that works.

Every operation below maps to a Core REST API endpoint. Use this table to jump to the request and response reference for any object kind:

Creating objects

Every create names the class the new object is an instance of, and what else you must supply depends on the object kind:

CreatingYou must supply
An entity
The class name and class version
A relationship
Both linked entity ids, the class name, and the class version
A user
The class version. The class name defaults to User
A channel
The class version. The class name defaults to Channel
A membership
channelId, userId, and the class version

Users, channels, and memberships can infer their class because they're built on built-in classes, which is why they need only a version. Generic entities and relationships have no default class, so they name one explicitly.

Beyond that, you can optionally supply your own id and a status, plus the payload itself. If you don't supply an id, the server generates one. Supplying an id that already exists is rejected with a 409 Conflict.

DataSync doesn't validate the payload against a JSON Schema. Write-time validation is limited to the properties you declare on the class: a value whose type doesn't match the declared valueKind is rejected with a 400, as is a missing value for a non-nullable property. Fields you haven't declared are stored as-is.

Request bodies are wrapped in a data object, and writes take a versioned vendor media type. In Bob's marketplace, creating product-sneaker-42 means naming the Product class at version 1:

POST /v1/datasync/subkeys/{subKey}/entities?auth=<token>
Content-Type: application/vnd.pubnub.objects.entity+json;version=1

{
"data": {
"id": "product-sneaker-42",
"entityClass": "Product",
"entityClassVersion": 1,
"status": "active",
"payload": { "name": "Retro Sneaker", "price": 89.99, "stock": 12 }
}
}

An unsupported Content-Type is rejected with a 415, and an Accept header DataSync can't satisfy with a 406.

1const response = await pubnub.dataSync.createEntity({
2 id: 'product-sneaker-42',
3 class: 'product',
4 data: {
5 classVersion: 1,
6 payload: { name: 'Retro Sneaker', price: 89.99, stock: 12 },
7 },
8})

Refer to Create entity (JavaScript), Create entity (C#), and Create entity (REST) for the full parameter reference.

Reading objects

Getting an object by ID returns its system fields and its payload. Refer to Get entity for the request and response reference, or to the endpoint table at the top of this page for the other object kinds. Projection filtering applies to payload and status only: the remaining system fields are always returned in full. For more information, refer to Projections.

A class that declares no /status property treats status as belonging to __default__ alone, so a read under a named projection drops it. To expose status to a named projection, declare a /status property tagged with that projection.

Expired objects are not returned by reads. Eventually consistent search (filter) can currently still return an object whose expiresAt has passed, so check expiresAt on results from that tier. For more information, refer to Data expiry (TTL).

Updating objects

DataSync supports two ways to update an object:

Replace

Replace is a PUT. Refer to Update entity, Update relationship, Update user, Update channel, or Update membership for the request and response reference.

Replace sends the new state for every payload field visible within the token's resolved projection, including the class version. Payload fields outside that projection are left untouched, not wiped and not required in the request body. The top-level status field has additional behavior described below.

For a class with no custom projections, where every field is in __default__, replace behaves exactly like sending the full object.

In Bob's marketplace, user-alice has display_name and avatar_url in both __default__ and admin projections, and email and phone in admin only, as described in Projections.

A token holding only __default__ can replace user-alice by sending just display_name and avatar_url. It doesn't need to know about or supply email and phone, and their values are left exactly as they were. A token holding admin can replace all four fields. Naming a field outside the token's resolved projection is rejected with a 403.

Two things to watch in a replace body:

  • The class version isn't a passive echo. Sending a different existing version of the same class re-points the object at that version. A version whose base Global class differs from the object's own is rejected with a 400, so a user can't be turned into a channel.
  • status is protected by its declared projections. If the resolved projection includes /status, omitting status from a replace body sets it to null. If the resolved projection excludes /status, changing it is rejected with a 403. In the current service implementation, omission is also treated as a change when the stored status is non-null, so that replace is rejected rather than preserving the hidden value.

A replace reaches only the fields in the token's projection. Everything outside it stays as it was, so a __default__ replace does not wipe the admin-only fields:

Partial update

Partial update is a PATCH. Refer to Patch entity, Patch relationship, Patch user, Patch channel, or Patch membership for the request and response reference.

Partial update uses JSON Patch (RFC 6902) with add, remove, replace, move, copy, and test operations, addressed by JSON Pointer paths into the object. A pointer starts at the object, where payload is one field alongside the system fields, so the price on a product is /payload/price.

A price change on product-sneaker-42 looks like this:

PATCH /v1/datasync/subkeys/{subKey}/entities/product-sneaker-42?auth=<token>
Content-Type: application/json-patch+json
If-Match: 1f0zq2h

[{ "op": "replace", "path": "/payload/price", "value": 79.99 }]

The patch document is a bare array rather than a body wrapped in data, it needs the application/json-patch+json content type, and it must contain at least one operation.

Which fields an update can address

A partial update can only address three paths on the object:

  • /status
  • /entityClassVersion, or /relationshipClassVersion for relationships and memberships
  • /payload, or anything beneath it

Any other pointer, including /id, /entityClass, /entityClassLevel, /createdAt, /updatedAt, /expiresAt, /eTag, and for relationships /entityAId, /entityBId, and /relationshipClass, is rejected with a 400 Bad Request, and the error lists the paths you can modify.

test is the one exception to this restriction: it can target any pointer, including the otherwise restricted system fields, because it never modifies the document. That makes it a way to assert /eTag inline as part of the patch, instead of relying on If-Match.

A replace body has no fields for id, the class name, the class level, or the linked entity ids. Sending them anyway isn't an error: they're ignored, and the request returns 200 OK with the object unchanged in those respects.

Memberships

Memberships support both replace and partial update. The same projection rules described above apply to both: a replace is scoped to the token's resolved projection, and fields outside that projection are left untouched.

Optimistic concurrency with ETags

Every entity and relationship carries an eTag that changes on every write. It's returned in the response body as data.eTag, and it's an opaque token, so send it back exactly as you received it, with no surrounding quotes.

If-Match is a request header on every write endpoint, documented per endpoint, for example on Patch entity.

If-Match is optional. Omit it and the write applies unconditionally. Send the eTag you last read as If-Match to make the write conditional, or If-Match: * to require only that the object still exists. If the value no longer matches the object's current eTag, the write is rejected with a 412 Precondition Failed instead of silently overwriting someone else's change.

The pattern is:

  1. read
  2. modify
  3. write with If-Match
  4. if 412, re-read and retry

In Bob's marketplace, two staff members both adjust the stock count on product-sneaker-42 around the same time. Both read the object and get the same eTag. The first write succeeds and changes the eTag. The second write, still carrying the old eTag, gets a 412 instead of overwriting the first change. The second staff member re-reads the updated stock count and applies their adjustment on top of it.


Combine ETags with events

If you're also subscribed to an object's events, use an incoming event as a signal to refresh your copy of the object (and its eTag) before you write, rather than writing.

Deleting objects and expiry

Delete removes an object immediately from reads. Like other writes, delete respects If-Match, so a delete against a stale eTag is rejected with a 412. Deleting an entity also deletes every relationship linked to it.

Refer to Delete entity, Delete relationship, Delete user, Delete channel, or Delete membership for the request and response reference.

A successful delete returns 200 OK with an empty body. Deleting an ID that doesn't exist returns a 404, so delete isn't idempotent. If you supply If-Match against an object that isn't there, you get a 412 rather than a 404.

In Bob's marketplace, deleting product-sneaker-42 deletes its ProductOwner relationship to seller-bob along with it. The relationships that get deleted, and therefore the linked-entity channels their delete events reach, are the ones that existed immediately before the entity was deleted. Refer to Where events are published for how a delete's event routing follows from this.

The cascade removes the linked relationships, but not the entities on the other end. Deleting product-sneaker-42 removes the ProductOwner link, while seller-bob stays:


Objects also expire automatically based on their class TTL. In short: expiresAt is set once at creation and is not refreshed by updates, and a relationship expires at the earlier of its two linked entities' expiry times. See the Data expiry (TTL) section on Schemas and validation for the full model.

Finding data

In DataSync, you can find and browse through data by:

The list endpoints are Get entities, Get relationships, Get users, Get channels, and Get memberships. Each documents the filter_fast, filter, sort, cursor, and limit query parameters described below.

Listing generic entities requires an entity_class, and listing generic relationships requires a relationship_class. There's no way to list every entity on a keyset regardless of class, and omitting the parameter is rejected with a 400. User, channel, and membership lists have no required parameter. To find the class names available on your keyset, list them with Get all entity class entries or Get all relationship classes.

Add entity_class_level (Global or SubKey) to disambiguate a keyset-level class from a Global class of the same name. Omit it and the keyset-level class wins.

Add entity_class_version (or relationship_class_version for relationships) to match only that version. Omit it to list instances across every version of the class, not just the latest one.

Filtering

DataSync offers two mutually exclusive filtering parameters: filter_fast for strongly consistent queries and filter for eventually consistent queries. Supplying both in one request is rejected with a 400.

Strongly consistent (filter_fast)Eventually consistent (filter)
Runs against
Properties declared with filtering mode simple or full
Properties declared with filtering mode full
Storage
The same storage used for writes
A separate search store
Consistency
Strongly consistent; reflects completed writes
Eventually consistent; can briefly lag recent writes
Predicate count
Maximum 10 predicates per expression currently
Not currently enforced

Both parameters share the same expression language, so switching between them doesn't mean rewriting your expression. The difference is which store answers the query, which properties are eligible, how fresh the results are, and the current predicate limit.

Operators
Comparison
==, !=, <, >, <=, >=
Pattern matching
LIKE (case-insensitive), SLIKE (case-sensitive), ILIKE (case-insensitive, same as LIKE)
Logical
&&, ||, !, and parentheses for grouping

Values can be strings in single or double quotes, numbers, true, false, or null. Recognized string escapes are \n, \t, \r, \\, \", and \', and \uXXXX isn't supported. Compare a date or datetime property against a quoted ISO-8601 string ("2026-08-14" for a date, "2026-08-14T00:00:00Z" for a datetime), and a malformed value is a 400. Property names are flat identifiers, so dotted paths like custom.public aren't supported.

null is the one value you can compare against a property of any type, but only with == and !=. Pairing null with an ordering operator (<, >, <=, >=) or a pattern operator (LIKE, SLIKE, ILIKE) is rejected with a 400.

Operator keywords and literals are case-insensitive, so like, Slike, true, and null all parse. A single = is not accepted as an equality operator.

In LIKE, SLIKE, and ILIKE patterns, * matches any sequence of characters. % and _ are literal. The eventually consistent filter parameter additionally treats ? as a single-character wildcard. There's currently no way to match a literal *.

note
LIKE is case-insensitive here

Unlike SQL, DataSync's LIKE ignores case. Use SLIKE when you need a case-sensitive match.

Pattern operators apply to string properties only, and the ordering operators (<, >, <=, >=) don't apply to boolean properties. Comparing a property to a value of the wrong type, a number property to a string for instance, is rejected with a 400. You filter on a property's name, and beyond the built-in fields only properties declared on the class are filterable.

In Bob's marketplace, filter_fast could find every Product with price < 100 and immediately reflect a completed write. filter could find products whose name matches a broader pattern, useful for a more search-like lookup across a larger catalog, while tolerating a short delay before a recent write appears in results.

Querying a parent entity class also matches instances of its subclasses. Relationship and membership lists match only the named relationship class, across its versions, with no descendant walk. Refer to Class inheritance on Schemas and validation.

Declare filterable properties first

Beyond the built-in fields, filtering only works on properties you've declared on the class, and only for data written after the property was declared. Data written earlier isn't retroactively indexed. See Property definitions.

Built-in fields

Four fields every object carries natively are filterable and sortable without being declared on the class:

FieldFilters as
id
string
createdAt
datetime
updatedAt
datetime
status
string

They work in both tiers and on a class that declares no properties at all, so status == "active" runs under filter_fast, createdAt > "2026-08-01T00:00:00Z" runs under filter, and sort=createdAt:desc orders by either. Compare createdAt and updatedAt against a quoted ISO-8601 datetime.

Because they're built in, these four names are reserved. A class property can't be named id, createdAt, or updatedAt, and status may only be declared as {"name": "status", "path": "/status"} to put the native field in a projection. Anything else is rejected with a 400 (DS-0906). Refer to Property definitions.

expiresAt and eTag are not in this set. Neither can be filtered or sorted on.

note
Declaring status can hide it from filters

If a class declares /status and scopes it to a named projection, a request whose token can't reach that projection loses status as a filter and sort target, because a list query can't resolve a single object's projection up front. A status you never declared stays filterable for every token. Refer to Projections.

Sorting and pagination

List endpoints accept a sort parameter: a comma-separated list of property names, each optionally followed by :desc for descending order, for example price:desc or name,price:desc.

A property with no suffix sorts ascending. Only :desc and :descending mean descending, and they're matched case-insensitively. Any other suffix, :asc included, sorts ascending without reporting an error, and a leading - isn't a descending marker, so -price is read as a property name and rejected with a 400.

You sort by a property's name, not its path. Beyond the built-in fields id, createdAt, updatedAt, and status, only declared properties are sortable.

Which properties are sortable depends on the filtering tier the request uses, because sorting runs in whichever store the query runs against:

RequestSortable properties
No filter, or filter_fast
filtering mode simple or full, plus the built-in fields
filter
filtering mode full only, plus the built-in fields

The consequence worth planning for: a simple property that sorts fine on its own is rejected when you combine it with filter. If you need to use eventually consistent filtering and sorting in one query, declare both properties as full.

Sorting by a property that isn't sortable in that context is rejected with a 400, and the error names the properties you can sort by. If you omit sort, results come back in a stable order by id. When you do sort, nulls sort last in both directions.

List endpoints use cursor-based pagination. Page size ranges from 1 to 100, with a default of 20. Pagination is forward-only, there is no cursor for a previous page, and there is no total count. An invalid cursor is rejected with a 400.

Membership lists additionally accept user_id and channel_id to narrow results, both documented on Get memberships. Both are optional and combine as an AND. They narrow results on the strongly consistent tier only: combining user_id or channel_id with filter currently ignores them and searches across all memberships.

In Bob's marketplace, listing product entities under $100, sorted by price descending, filters strongly consistently and paginates in one call:

1const response = await pubnub.dataSync.getEntities({
2 entityClass: 'product',
3 filterFast: 'price < 100',
4 sort: { price: 'desc' },
5 limit: 20,
6})
7console.log(response.data)
8console.log(response.meta)

To page forward, pass the response's next_cursor back as cursor on the next call, and stop when has_next is false. Refer to Get all entities (JavaScript), Get all entities (C#), and Get entities (REST) for the full parameter reference, including the equivalent list methods for users, channels, memberships, and relationships.