Shoplive Developers
한국어KOEnglishEN日本語JA

Shoplive API Getting Started

Learn the Shoplive External API v2 through three topics. Once authentication and conventions are in place, you go from your first broadcast → product catalog sync → collecting broadcast results, which covers the integrations most teams need in production.

Follow-along tips On a wide screen, enter your accessKey and campaignKey in the right panel. They are substituted into every example on this page, so you can copy and run as you read. The phone below shows what viewers see at the step you are reading. Full field definitions for every endpoint live in the API Reference.
Getting started

Overview & prerequisites

External API v2 is a REST API for managing Shoplive campaigns, products, sellers, brands, shortforms and statistics server-to-server from your own systems (admin tools, back office, commerce backend). Almost everything you can do in the Shoplive Admin console can be automated through it.

AudienceBackend developers comfortable with REST and JWT
TimeYour first broadcast takes about 20 minutes once you have your keys. Product catalog sync and Collect broadcast results take about 30 minutes each
You needaccessKey · secretKey and a server environment that can make HTTPS requests

Prerequisites

  • accessKey / secretKey — an API key pair issued per customer account. Find it in Shoplive Admin or ask your Shoplive account manager. Keep secretKey on your servers only; never ship it in a browser or mobile app.
  • A server environment that can make HTTPS requests — examples use curl, with token-generation samples in Node.js, Python, Java, Go and Ruby.
  • External API v2 enabled — API v2 must be turned on for your account. Check with your Shoplive account manager.
  • (Optional) A streaming tool — OBS or any RTMP encoder, for when you actually push video.

Base URL

EnvironmentBase URLNotes
Productionhttps://private.shopliveapi.comThere is a single environment. Test with rehearsal broadcasts and test products on your production account.

Every v2 endpoint has the shape /v2/{accessKey}/.... The {accessKey} in the path must match the accessKey claim inside your token.

The three topics

Next → Authentication (JWT)

Getting started

Authentication (JWT)

The External API authenticates with a JWT that you sign yourself. There is no token-issuing endpoint to call. Sign a token with your secretKey using HS256 and send it in the Authorization: Bearer header.

Token format

ItemValueDescription
AlgorithmHS256The signing key is your secretKey.
Claim accessKeyRequiredYour customer accessKey. Sign with the secretKey of the same account.
Claim expRequiredExpiry. This is a server-to-server credential, so a long lifetime (1–3 years) reused from server config is fine.
Claim iatRecommendedIssued-at time.
HeaderAuthorization: Bearer {JWT}Send on every request.
secretKey is a Base64 string The secretKey you receive from Admin is Base64-encoded. Java's JJWT (0.9.x) Base64-decodes string keys automatically, but most other libraries (Node.js, Python, …) need the decoded bytes as the signing key for the signature to verify on the server. The samples below already account for this.

Token generation samples

// npm i jsonwebtoken
const jwt = require('jsonwebtoken');

function createShopliveToken(accessKey, base64SecretKey) {
  const key = Buffer.from(base64SecretKey, 'base64'); // sign with the decoded bytes
  return jwt.sign({ accessKey }, key, { algorithm: 'HS256', expiresIn: '365d' });
}
# pip install pyjwt
import base64, time, jwt

def create_shoplive_token(access_key: str, base64_secret_key: str) -> str:
    key = base64.b64decode(base64_secret_key)  # sign with the decoded bytes
    now = int(time.time())
    payload = {"accessKey": access_key, "iat": now, "exp": now + 365 * 24 * 3600}
    return jwt.encode(payload, key, algorithm="HS256")
// build.gradle: implementation 'io.jsonwebtoken:jjwt:0.9.1'
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;

public class ShopliveToken {
    public static String create(String accessKey, String base64SecretKey) {
        long oneYear = 365L * 24 * 60 * 60 * 1000;
        return Jwts.builder()
            .signWith(SignatureAlgorithm.HS256, base64SecretKey) // string keys are treated as Base64
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + oneYear))
            .claim("accessKey", accessKey)
            .compact();
    }
}
// go get github.com/golang-jwt/jwt/v5
package shoplive

import (
	"encoding/base64"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

func CreateShopliveToken(accessKey, base64SecretKey string) (string, error) {
	key, err := base64.StdEncoding.DecodeString(base64SecretKey) // sign with the decoded bytes
	if err != nil {
		return "", err
	}
	now := time.Now()
	claims := jwt.MapClaims{
		"accessKey": accessKey,
		"iat":       now.Unix(),
		"exp":       now.Add(365 * 24 * time.Hour).Unix(),
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString(key)
}
# gem install jwt
require 'jwt'
require 'base64'

def create_shoplive_token(access_key, base64_secret_key)
  key = Base64.decode64(base64_secret_key) # sign with the decoded bytes
  now = Time.now.to_i
  payload = { accessKey: access_key, iat: now, exp: now + 365 * 24 * 3600 }
  JWT.encode(payload, key, 'HS256')
end

Verify with a first call

List campaigns with the token to confirm authentication works. Put your token where the examples say YOUR_JWT. The token is a secret, so the right panel does not ask for it.

bash
curl -s "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign?page=1&count=5" \
  -H "Authorization: Bearer YOUR_JWT"

On success you get 200 with a results array (empty if you have no campaigns yet). On failure you get 401 and a body like this:

401 Unauthorized
{ "_s": -824, "_e": "Invalid token" }
  • Token does not exist. — the Authorization header is missing or its value is empty. Check that you sent the header as Authorization: Bearer <token>.
  • Invalid token — signature mismatch (wrong secretKey or missing Base64 handling), missing accessKey claim, or an unknown accessKey.
  • Expired token (_s: -412) — exp has passed. Generate a new token.
  • _s: -824 even though the token is valid — check that the accessKey in the URL matches the token's accessKey claim. If it does, External API v2 access may not be enabled for the account; ask your Shoplive account manager to enable it.

Previous ← OverviewNext → Conventions

Getting started

Request & response conventions

These rules apply to every endpoint. Once you know them, any API in the reference works the same way.

Format

  • Request and response bodies are JSON (UTF-8). Send Content-Type: application/json with any request that has a body.
  • Timestamps are ISO 8601 date-time strings with a UTC offset, e.g. 2026-10-01T20:00:00+09:00 or 2026-10-01T11:00:00Z. Responses are normalized to UTC (Z).
  • Success is HTTP 200 or 204. Endpoints with nothing to return (start / end broadcast, etc.) respond with an empty body.

Identifiers

IdentifierDescription
accessKeyYour customer key. First path segment of every endpoint.
campaignKey / campaignIdCampaign identifiers. Wherever a path says {campaignKeyOrId}, either one works. Player and SDK integrations use campaignKey.
productId / customerProductIdProduct identifiers. customerProductId is your own product ID; set it on creation and it becomes the key for de-duplication, updates and attaching.
sellerId, brandIdSeller and brand identifiers, used when you partition campaigns and products per seller.

Lists and pagination

List endpoints share the same query parameters and respond with results + totalCount.

ParameterDefaultDescription
page11-based page number.
count10Page size. Campaign lists allow up to 20. Defaults and limits vary by API (product list: default 30, max 100).
keyword—Text search on title and similar fields. The product list uses name, brand, sku, url instead of keyword.
sortAscendingfalseSort direction. Campaign lists sort by scheduled time.
List response shape
{
  "results": [ { "...": "item" } ],
  "totalCount": 42
}

Bulk responses

Endpoints that take arrays (register, update, attach products) return a per-item result. HTTP may be 200 while some items failed, so always check failCount.

Bulk response shape
{
  "results": [
    { "customerProductId": "SKU-1001", "status": "CREATE" },
    { "customerProductId": "SKU-1002", "status": "FAIL", "message": "..." }
  ],
  "successCount": 1, "warnCount": 0, "failCount": 1, "totalCount": 2
}

Error responses

A response is an error if the HTTP status is 400 or higher, or if the JSON body has a negative _s. Check both the status code and _s. Successful responses never carry a negative _s.

Error body
{
  "_s": -710,
  "_e": "Campaign does not exist"
}

_s is the Shoplive status code (negative) and _e is a human-readable message. Depending on the response, the body may be empty or in a different shape.

Common codes are listed under Error codes. Have your client check both the HTTP status and _s to decide whether a call failed, and keep _s and _e (or message) for logs and support requests. Some responses, such as the bulk product APIs, return HTTP 200 and report per-item failures as status: "FAIL", so also check failCount and each item's message.

Rate limits There is no published fixed limit for general endpoints, but instead of polling the same resource every second, subscribe to webhooks for state changes.

Previous ← AuthenticationNext → Your first broadcast

Mission

Your first broadcast

Create a campaign with the API, attach products, and finish by showing the player on your own web page. Hosts usually start and end broadcasts from the Shoplive console, so that part is an optional last step.

Done whenThe player for your scheduled campaign opens on your page and lists two products
APIs usedCampaign · Product · Console (optional) + Web SDK
TimeAbout 20 minutes
  1. Mission 1 · Create a campaign — create it with a title and schedule, and receive a campaignKey.
  2. Mission 2 · Register products — register products under your own product IDs (customerProductId).
  3. Mission 3 · Attach products to the campaign — attach them to the campaign and set the order.
  4. Mission 4 · Get stream info — RTMP URL, stream key and player URL.
  5. Mission 5 · Show the player on your page — a few lines of Web SDK code.
  6. Mission 6 · (Optional) Start & end via API — when you need automation instead of the console.

Previous ← ConventionsNext → Mission 1 · Create a campaign

Mission · Your first broadcast

Mission 1 · Create a campaign

A campaign is Shoplive's basic unit: one broadcast. A title is all you need to create one, and the response already includes the player URL and stream credentials.

POST/v2/{accessKey}/campaignReference ↗
bash
curl -s -X POST "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "campaignMeta": {
      "title": "Autumn New Arrivals Live",
      "description": "Oct 1, 8 PM KST",
      "tags": ["autumn", "new"],
      "serveReplay": true,
      "scheduledStartAt": "2026-10-01T20:00:00+09:00",
      "scheduledEndAt":   "2026-10-01T21:00:00+09:00"
    }
  }'
FieldRequiredDescription
campaignMeta.titleRequiredCampaign title. Missing → _s: -837.
campaignMeta.descriptionInternal memo.
campaignMeta.tagsArray of tags for search and grouping.
campaignMeta.serveReplayWhether to publish the replay after the broadcast ends.
campaignMeta.scheduledStartAt / scheduledEndAtScheduled times, shown in the player's "coming up" state.
campaignMeta.rehearsalPasscodePasscode for the rehearsal.
seller.sellerId, campaignMeta.brand.brandIdSet these to create the campaign under a seller or brand.
200 OK (example, abbreviated)
{
  "campaignMeta": {
    "campaignId": "123456",
    "campaignKey": "a1b2c3d4e5f6",
    "shoplivePlayerUrl": "https://player.shoplive.cloud/...",
    "title": "Autumn New Arrivals Live",
    "campaignStatus": "READY",
    "serveReplay": true,
    "tags": ["autumn", "new"],
    "scheduledStartAt": "2026-10-01T11:00:00Z",
    "scheduledEndAt": "2026-10-01T12:00:00Z"
  },
  "stream": {
    "streamUrl": "rtmp://...",
    "streamKey": "live_...",
    "liveUrl": "https://.../playlist.m3u8",
    "videoAspectRatio": "9:16"
  }
}

Save campaignMeta.campaignKey from the response. Every following step uses it. Enter it in the campaignKey field of the right panel and the examples below update immediately.

Campaign status (campaignStatus)

StatusMeaning
RESERVEDScheduled to start automatically.
READYCan be started. Default right after creation; the player shows the "coming up" screen.
REHEARSALRehearsal in progress; only people with the passcode (rehearsalPasscode) can watch. Returns to READY when ended with end.
ONAIRLive.
ENDEDFinished. Replay is served if serveReplay is true.

Previous ← Your first broadcastNext → Mission 2 · Register products

Mission · Your first broadcast

Mission 2 · Register products

Register products once per account and reuse them across campaigns. Send an array to register several at a time.

POST/v2/{accessKey}/productReference ↗
bash
curl -s -X POST "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/product" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "customerProductId": "SKU-1001",
      "name": "Wool Blend Coat",
      "brand": "Shoplive Apparel",
      "url": "https://shop.example.com/products/1001",
      "imageUrl": "https://cdn.example.com/1001.jpg",
      "currency": "USD",
      "originalPrice": 259,
      "discountPrice": 199,
      "showPrice": true
    },
    {
      "customerProductId": "SKU-1002",
      "name": "Cashmere Scarf",
      "url": "https://shop.example.com/products/1002",
      "imageUrl": "https://cdn.example.com/1002.jpg",
      "currency": "USD",
      "originalPrice": 89
    }
  ]'
FieldRequiredDescription
customerProductIdRecommendedYour own unique product ID. Always set it to follow the sync flow in this guide. It is the key for updates, attaching and lookups. If the value already exists, that item comes back as status: FAIL; use the PUT in Mission 7 to update.
nameRequiredProduct name.
urlRequiredProduct detail page. Where viewers land when they tap the product in the player.
imageUrlRecommendedThumbnail image.
currency, originalPrice, discountPrice, discountRatePricing.
showPriceWhether to show the price (default true). Include it in updates (PUT) as well.
brand, brandIdentifierBrand display name and your brand code.
sku, description, ctaButtonTitleExtra details. Use ctaButtonTitle to change the button label (contact Shoplive before using it).
seller.sellerIdSet to create the product under a seller. Omit for a customer-owned product.
200 OK (example)
{
  "results": [
    { "status": "CREATE", "productId": "90001", "customerProductId": "SKU-1001", "name": "Wool Blend Coat", "...": "..." },
    { "status": "CREATE", "productId": "90002", "customerProductId": "SKU-1002", "name": "Cashmere Scarf", "...": "..." }
  ],
  "successCount": 2, "warnCount": 0, "failCount": 0, "totalCount": 2
}

Previous ← Mission 1 · Create a campaignNext → Mission 3 · Attach products to the campaign

Mission · Your first broadcast

Mission 3 · Attach products to the campaign

Attach registered products to the campaign. This endpoint is additive: the products you send are appended to the end of the campaign's product list in the order given. Products that are already attached are not added again.

POST/v2/{accessKey}/campaign/{campaignKeyOrId}/productReference ↗
bash
curl -s -X POST "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign/CAMPAIGN_KEY/product" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[ { "customerProductId": "SKU-1001" }, { "customerProductId": "SKU-1002" } ]'
FieldDescription
customerProductId or productIdThe product to attach. Use one kind per request.
200 OK (example · SKU-1002 already attached)
{
  "results": [
    { "customerProductId": "SKU-1001", "status": "SUCCESS", "message": null },
    { "customerProductId": "SKU-1002", "status": "FAIL", "message": "The product is already registered." }
  ],
  "successCount": 1, "failCount": 1, "totalCount": 2
}

Already-attached products return FAIL (The product is already registered.) for that item, and the other items are attached as usual. You can safely send the same request again. The order is set with a separate endpoint: products are sorted in the order you list them, and anything you leave out moves to the end.

PUT/v2/{accessKey}/campaign/{campaignKeyOrId}/product/orderReference ↗
bash
curl -s -X PUT "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign/CAMPAIGN_KEY/product/order" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[ { "customerProductId": "SKU-1002" }, { "customerProductId": "SKU-1001" } ]'

Previous ← Mission 2 · Register productsNext → Mission 4 · Get stream info

Mission · Your first broadcast

Mission 4 · Get stream info

Fetching a single campaign returns its metadata together with the RTMP ingest credentials, the viewer-facing player URL, and the attached products.

GET/v2/{accessKey}/campaign/{campaignKeyOrId}Reference ↗
bash
curl -s "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign/CAMPAIGN_KEY" \
  -H "Authorization: Bearer YOUR_JWT"
PathUse
stream.streamUrl + stream.streamKeyEnter these as the server URL and stream key in OBS or your encoder. Once streaming, the console preview shows video.
campaignMeta.shoplivePlayerUrlA ready-to-open viewer page.
campaignMeta.campaignKeyThe value you pass to the Web SDK in the next step.
stream.liveUrlHLS (m3u8) URL for your own player.
campaignProductList[]Products attached in Mission 3, with sortOrder.
Streaming and campaign status are separate Your encoder pushing video and the campaign switching to ONAIR are two different things. The usual order is: start the encoder, confirm the console preview, then the host presses Start in the console (or use the API in Mission 6).

Previous ← Mission 3 · Attach products to the campaignNext → Mission 5 · Show the player on your page

Mission · Your first broadcast

Mission 5 · Show the player on your page

A campaign created via the API can be shown on your page with a few lines of Web SDK code. Reaching this step completes Your first broadcast. While the campaign is READY the player shows the "coming up" screen; once ONAIR it shows the live stream.

html
<script src="https://static.shoplive.cloud/shoplive.js"></script>
<div id="shoplive-player"></div>
<script>
  cloud.shoplive.init({ accessKey: "YOUR_ACCESS_KEY" });
  cloud.shoplive.setPlayer("shoplive-player", { campaignKey: "CAMPAIGN_KEY" });
</script>
  • Web — for user identity, cart and purchase callbacks and the rest of the integration, follow the Web SDK guide.
  • Android · iOS — the Mobile SDK opens the player with the same accessKey and campaignKey.
  • Broadcast list page — a common pattern is GET /v2/{accessKey}/campaign?campaignStatus=READY to fetch upcoming campaigns (if you scheduled auto start for a saved-video campaign in the admin, it is RESERVED; query it separately), render them in your UI, and open the player with the code above on click.

Previous ← Mission 4 · Get stream infoNext → Mission 6 · (Optional) Start & end via API

Mission · Your first broadcast

Mission 6 · (Optional) Start & end via API

If a scheduler rather than a host turns broadcasts on and off, use the Console API. With your encoder already pushing video to streamUrl, calling start makes the live stream visible to viewers.

POST/v2/{accessKey}/console/{campaignKeyOrId}/start?rehearsal=trueReference ↗
POST/v2/{accessKey}/console/{campaignKeyOrId}/endReference ↗
bash
# Start a rehearsal (only people with the passcode can watch)
curl -s -X POST "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/console/CAMPAIGN_KEY/start?rehearsal=true" \
  -H "Authorization: Bearer YOUR_JWT" -o /dev/null -w "%{http_code}\n"

# End the rehearsal → READY
curl -s -X POST "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/console/CAMPAIGN_KEY/end" \
  -H "Authorization: Bearer YOUR_JWT" -o /dev/null -w "%{http_code}\n"

# Go live → ONAIR
curl -s -X POST "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/console/CAMPAIGN_KEY/start" \
  -H "Authorization: Bearer YOUR_JWT" -o /dev/null -w "%{http_code}\n"

# End → ENDED
curl -s -X POST "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/console/CAMPAIGN_KEY/end" \
  -H "Authorization: Bearer YOUR_JWT" -o /dev/null -w "%{http_code}\n"

Ending a rehearsal with end returns the broadcast to READY. Then call start to go live.

Calling start without a stream sets the status to ONAIR but viewers see a waiting screen. If you automate this, first check that the encoder is streaming (console preview or the stream.liveUrl response).

Previous ← Mission 5 · Show the player on your pageNext → Product catalog sync

Mission

Product catalog sync

Your product DB is the source of truth and Shoplive should be a copy of it. Product catalog sync builds a sync routine that creates, updates and deletes products keyed by customerProductId, and shows how to push sold-out and stock changes to viewers instantly during a broadcast.

Done whenA single batch run mirrors your product changes into Shoplive, and a sold-out during a broadcast shows in the player immediately
APIs usedProduct · Campaign product · Console · Seller
PrerequisiteThe campaign and two products from Your first broadcast

Previous ← Mission 6 · (Optional) Start & end via APINext → Mission 7 · Product upsert pattern

Mission · Product catalog sync

Mission 7 · Product upsert pattern

Product registration (POST) returns status: FAIL for items whose customerProductId already exists. Update (PUT) finds its target by customerProductId. Combine the two and you have a sync routine.

  1. Send every changed product with POST. New ones come back as CREATE, existing ones as FAIL.
  2. Collect the items that came back FAIL and send them again with PUT. Items with no changes also respond UPDATE. The message text depends on the language setting, so don't match on the string.
  3. Clean up products deleted on your side with DELETE. To avoid deleting products still attached to a campaign, add ?force=false. Products attached to a campaign are then not deleted and come back as FAIL. The default (true) removes the product from campaign product lists and deletes it.
PUT/v2/{accessKey}/productReference ↗
bash
# Apply a price drop: find the target by customerProductId and update
curl -s -X PUT "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/product" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[
    { "customerProductId": "SKU-1001", "name": "Wool Blend Coat", "url": "https://shop.example.com/products/1001",
      "currency": "USD", "originalPrice": 259, "discountPrice": 179, "showPrice": true }
  ]'
DELETE/v2/{accessKey}/product?force=falseReference ↗
bash
# force=false: products attached to a campaign are not deleted and return FAIL (default true removes them from campaign product lists and deletes)
curl -s -X DELETE "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/product?force=false" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[ { "customerProductId": "SKU-OLD-0001" } ]'

The examples below assume the JWT you created in Authentication is stored in the SL_TOKEN environment variable.

async function syncProducts(products) {           // products: changed rows from your DB
  const created = await sl('POST', '/product', products);
  const existing = created.results
    .filter(r => r.status === 'FAIL')
    .map(r => products.find(p => p.customerProductId === r.customerProductId));
  const updated = existing.length ? await sl('PUT', '/product', existing) : { results: [] };
  return { created: created.successCount, updated: updated.successCount,
           failed: updated.results.filter(r => r.status === 'FAIL') };
}

async function sl(method, path, body) {
  const res = await fetch(`https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY${path}`, {
    method, headers: { Authorization: `Bearer ${process.env.SL_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  });
  const text = await res.text();
  let data = null;
  try { data = JSON.parse(text); } catch {}   // the body may be empty or not JSON
  if (!res.ok || data?._s < 0) throw new Error(`${res.status} ${text}`);   // error if HTTP ≥ 400 or _s < 0 (see Error responses)
  return data;
}
import os, requests

BASE = "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY"
H = {"Authorization": f"Bearer {os.environ['SL_TOKEN']}", "Content-Type": "application/json"}

def sl(method, path, body):
    r = requests.request(method, BASE + path, json=body, headers=H, timeout=10)
    try:
        data = r.json()
    except ValueError:            # the body may be empty or not JSON
        data = None
    s = data.get("_s") if isinstance(data, dict) else None
    if r.status_code >= 400 or (isinstance(s, (int, float)) and s < 0):
        raise RuntimeError(f"{r.status_code} {r.text}")   # error if HTTP ≥ 400 or _s < 0 (see Error responses)
    return data

def sync_products(products):      # products: changed rows from your DB (list of dicts)
    created = sl("POST", "/product", products)
    by_id = {p["customerProductId"]: p for p in products}
    existing = [by_id[r["customerProductId"]] for r in created["results"]
                if r["status"] == "FAIL"]
    updated = sl("PUT", "/product", existing) if existing else {"results": [], "successCount": 0}
    return created["successCount"], updated["successCount"]
// build.gradle: implementation 'org.json:json:20240303'
import org.json.JSONArray;
import org.json.JSONObject;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class ProductSync {
    static final HttpClient CLIENT = HttpClient.newHttpClient();

    static JSONObject syncProducts(JSONArray products) throws Exception {   // products: changed rows from your DB
        JSONObject created = sl("POST", "/product", products);
        JSONArray createdResults = created.getJSONArray("results");
        JSONArray existing = new JSONArray();
        for (int i = 0; i < createdResults.length(); i++) {
            JSONObject r = createdResults.getJSONObject(i);
            if ("FAIL".equals(r.getString("status"))) {
                String cid = r.getString("customerProductId");
                for (int j = 0; j < products.length(); j++) {
                    JSONObject p = products.getJSONObject(j);
                    if (cid.equals(p.getString("customerProductId"))) { existing.put(p); break; }
                }
            }
        }
        JSONObject updated = existing.length() > 0
            ? sl("PUT", "/product", existing)
            : new JSONObject().put("results", new JSONArray()).put("successCount", 0);
        JSONArray failed = new JSONArray();
        JSONArray updatedResults = updated.getJSONArray("results");
        for (int i = 0; i < updatedResults.length(); i++) {
            JSONObject r = updatedResults.getJSONObject(i);
            if ("FAIL".equals(r.getString("status"))) failed.put(r);
        }
        return new JSONObject()
            .put("created", created.getInt("successCount"))
            .put("updated", updated.getInt("successCount"))
            .put("failed", failed);
    }

    static JSONObject sl(String method, String path, Object body) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY" + path))
            .header("Authorization", "Bearer " + System.getenv("SL_TOKEN"))
            .header("Content-Type", "application/json")
            .method(method, HttpRequest.BodyPublishers.ofString(body.toString()))
            .build();
        HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
        String text = response.body() == null ? "" : response.body().trim();
        JSONObject json = text.startsWith("{") ? new JSONObject(text) : new JSONObject(); // the body may be empty or not JSON
        if (response.statusCode() >= 400 || json.optInt("_s", 0) < 0) {
            throw new RuntimeException(response.statusCode() + " " + text); // error if HTTP ≥ 400 or _s < 0 (see Error responses)
        }
        return json;
    }
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

type slResult struct {
	CustomerProductID string `json:"customerProductId"`
	Status             string `json:"status"`
}

type slResponse struct {
	S            int        `json:"_s"`
	Results      []slResult `json:"results"`
	SuccessCount int        `json:"successCount"`
}

// products: changed rows from your DB
func syncProducts(products []map[string]interface{}) (map[string]interface{}, error) {
	created, err := sl("POST", "/product", products)
	if err != nil {
		return nil, err
	}
	var existing []map[string]interface{}
	for _, r := range created.Results {
		if r.Status == "FAIL" {
			for _, p := range products {
				if p["customerProductId"] == r.CustomerProductID {
					existing = append(existing, p)
					break
				}
			}
		}
	}
	var updated slResponse
	if len(existing) > 0 {
		u, err := sl("PUT", "/product", existing)
		if err != nil {
			return nil, err
		}
		updated = *u
	}
	var failed []slResult
	for _, r := range updated.Results {
		if r.Status == "FAIL" {
			failed = append(failed, r)
		}
	}
	return map[string]interface{}{
		"created": created.SuccessCount,
		"updated": updated.SuccessCount,
		"failed":  failed,
	}, nil
}

func sl(method, path string, body interface{}) (*slResponse, error) {
	buf, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequest(method, "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY"+path, bytes.NewReader(buf))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("SL_TOKEN"))
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	data, err := io.ReadAll(res.Body)
	if err != nil {
		return nil, err
	}
	var out slResponse
	jsonErr := json.Unmarshal(data, &out) // the body may be empty or not JSON
	if res.StatusCode >= 400 || (jsonErr == nil && out.S < 0) {
		return nil, fmt.Errorf("%d %s", res.StatusCode, string(data)) // error if HTTP ≥ 400 or _s < 0 (see Error responses)
	}
	if jsonErr != nil {
		return nil, jsonErr
	}
	return &out, nil
}
require 'net/http'
require 'json'
require 'uri'

# products: changed rows from your DB
def sync_products(products)
  created = sl('POST', '/product', products)
  existing = created['results']
    .select { |r| r['status'] == 'FAIL' }
    .map { |r| products.find { |p| p['customerProductId'] == r['customerProductId'] } }
  updated = existing.any? ? sl('PUT', '/product', existing) : { 'results' => [], 'successCount' => 0 }
  {
    'created' => created['successCount'],
    'updated' => updated['successCount'],
    'failed' => updated['results'].select { |r| r['status'] == 'FAIL' }
  }
end

def sl(method, path, body)
  uri = URI("https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY#{path}")
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req['Authorization'] = "Bearer #{ENV['SL_TOKEN']}"
  req['Content-Type'] = 'application/json'
  req.body = body.to_json
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
  data = JSON.parse(res.body.to_s) rescue nil   # the body may be empty or not JSON
  s = data.is_a?(Hash) ? data['_s'] : nil
  raise "#{res.code} #{res.body}" if res.code.to_i >= 400 || (s.is_a?(Numeric) && s < 0)  # error if HTTP ≥ 400 or _s < 0 (see Error responses)
  data
end
Batch size Sending a few dozen items per request keeps partial failures manageable. Log each item's status in your sync log. Values differ by API: create CREATE·WARN·FAIL / update UPDATE·FAIL / delete DELETE·FAIL. WARN means the product was created but needs a check.

Previous ← Product catalog syncNext → Mission 8 · Update campaign products

Mission · Product catalog sync

Mission 8 · Update campaign products

The list of products attached to a campaign is managed with four operations. Except for detach all, they take an array of customerProductId in the body.

OperationEndpointNotes
AttachPOST /v2/{accessKey}/campaign/{campaignKeyOrId}/productAppends. Already-attached items come back as FAIL.
DetachDELETE /v2/{accessKey}/campaign/{campaignKeyOrId}/productRemoves from the campaign only; the product itself stays.
ReorderPUT /v2/{accessKey}/campaign/{campaignKeyOrId}/product/orderSorted in the order listed; unlisted items move to the end.
Detach allDELETE /v2/{accessKey}/campaign/{campaignKeyOrId}/product/allEmpty the list and rebuild it.
bash
# Detach one product from the campaign
curl -s -X DELETE "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign/CAMPAIGN_KEY/product" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[ { "customerProductId": "SKU-1002" } ]'

If the goal is "make the campaign's product list match my merchandising screen exactly", the simplest sequence is detach all → attach → reorder. During a live broadcast the list is briefly empty, so do this before the broadcast or compute the diff and attach/detach only what changed.

Previous ← Mission 7 · Product upsert patternNext → Mission 9 · Live stock & sold-out

Mission · Product catalog sync

Mission 9 · Live stock & sold-out

When stock changes during a broadcast, change the product's state in that campaign with the Console API. Viewers see it immediately. Unlike the PUT that edits the product itself (price, name), this API only changes how the product is shown in one campaign.

PUT/v2/{accessKey}/console/{campaignKeyOrId}/product/stockStatusReference ↗
PUT/v2/{accessKey}/console/{campaignKeyOrId}/product/showReference ↗
bash
# Mark as sold out
curl -s -X PUT "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/console/CAMPAIGN_KEY/product/stockStatus?stockStatus=SOLD_OUT" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[ { "customerProductId": "SKU-1002" } ]'

# Back in stock → available again
curl -s -X PUT "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/console/CAMPAIGN_KEY/product/stockStatus?stockStatus=IN_STOCK" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[ { "customerProductId": "SKU-1002" } ]'

# Turn on the "showing now" highlight
curl -s -X PUT "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/console/CAMPAIGN_KEY/product/show?show=true" \
  -H "Authorization: Bearer YOUR_JWT" \
  -H "Content-Type: application/json" \
  -d '[ { "customerProductId": "SKU-1001" } ]'
stockStatusShown as
IN_STOCKAvailable.
LOW_IN_STOCKLow stock indicator.
SOLD_OUTSold out. The purchase button is disabled.
Where to hook it in A small worker that listens to sold-out events from your inventory system and calls this API means the host never has to mark items sold out by hand in the console. Restricting it to campaigns that are ONAIR keeps the call volume down.

Previous ← Mission 8 · Update campaign productsNext → Mission 10 · Split by seller

Mission · Product catalog sync

Mission 10 · Split by seller

If you run a marketplace and operate campaigns and products per seller, set seller.sellerId when creating resources and use the sellerId filter on list endpoints. Existing resources can be moved with the transfer endpoints.

GoalHow
Create and look up sellersSeller API — POST /v2/{accessKey}/seller
Create products and campaigns owned by a seller"seller": { "sellerId": 123 } in the body
Move existing products and campaigns to a sellerPUT /v2/{accessKey}/product/transferToSeller/{sellerId}, PUT /v2/{accessKey}/campaign/{campaignKeyOrId}/transferToSeller/{sellerId}. To move the campaign's attached products as well, add ?withProduct=true to the campaign transfer.
List only one seller's itemssellerId=123 on the campaign or product list API

Previous ← Mission 9 · Live stock & sold-outNext → Collect broadcast results

Mission

Collect broadcast results

After a broadcast ends, pull viewing and engagement metrics plus raw logs into your data warehouse, and link the replay URL from your pages. The key is to detect the end with a webhook instead of polling.

Done whenAfter a broadcast ends, a statistics summary and chat / product-click logs land in your DB automatically and the replay link is published
APIs usedWebhook · Stats · Log · Campaign
PrerequisiteOne ended campaign (ending the campaign from Your first broadcast is fine)

Previous ← Mission 10 · Split by sellerNext → Mission 11 · Detect the end via webhook

Mission · Collect broadcast results

Mission 11 · Detect the end via webhook

Webhooks are how Shoplive sends broadcast events to your endpoint. Setup is not self-service: it is registered on the Shoplive side, so send your receiving URL to your Shoplive account manager or ask@shoplive.cloud.

EventUse
Campaign startTurn on a "LIVE" badge on your page, send push notifications
Campaign endThe trigger for this topic. Collect statistics and logs, publish the replay
Auction start · end · resetSettle winning bids for auction broadcasts
  • Return 200 within about one second from the receiver and do the real work asynchronously from a queue. Aggregation can take a few minutes after the end, so scheduling the collection job slightly later is safer.
  • Each event is sent once. Also run the polling below periodically as a reconciliation job. The same campaign can arrive by both routes, so process idempotently by campaignKey.
  • The body of start and end events is the same Campaign object the campaign API returns. Read campaignMeta.campaignKey, campaignMeta.campaignStatus …. Start and end events go to separate URLs, and auction events go to a third URL (the set of events is configurable).
  • Verify the signature (start and end events only, and only if a webhook secret key was configured). Two headers arrive with the request: X-SHOPLIVE-WEBHOOK-TIMESTAMP (Unix epoch milliseconds, as a string) and X-SHOPLIVE-WEBHOOK-SIGNATURE (lowercase hex HMAC-SHA256). The signed message is the timestamp + . + the raw request body bytes exactly as received (before JSON parsing), and the key is the UTF-8 bytes of the secret string. Compare in constant time.
  • Rehearsal broadcasts do not send webhooks unless that option was enabled at registration.
  • For field details, see the Webhook reference.

A signature verification example. WEBHOOK_SECRET is the webhook secret key set at registration, and you verify against the raw bytes of the body before parsing it.

const crypto = require('crypto');
const express = require('express');
const app = express();

// Express: verify against the raw Buffer, before parsing
app.post('/webhooks/shoplive', express.raw({ type: 'application/json' }), (req, res) => {
  const ts = req.get('X-SHOPLIVE-WEBHOOK-TIMESTAMP') || '';
  const sig = req.get('X-SHOPLIVE-WEBHOOK-SIGNATURE') || '';
  const expected = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)   // secret string (UTF-8)
    .update(Buffer.concat([Buffer.from(ts + '.'), req.body])).digest('hex');
  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);
  res.sendStatus(200);   // respond within about 1 second and hand the work to a queue
  const body = JSON.parse(req.body);   // body.campaignMeta.campaignKey
});
import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/shoplive")
def shoplive_webhook():
    raw = request.get_data()   # raw bytes, before parsing
    ts = request.headers.get("X-SHOPLIVE-WEBHOOK-TIMESTAMP", "")
    sig = request.headers.get("X-SHOPLIVE-WEBHOOK-SIGNATURE", "")
    expected = hmac.new(os.environ["WEBHOOK_SECRET"].encode(),
                        ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401)
    return "", 200   # return 200 within about 1 second and hand the work to a queue
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;   // Java 17+

static boolean verify(String secret, String timestamp, byte[] rawBody, String signature) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
    String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
    return MessageDigest.isEqual(   // constant-time comparison
        expected.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8));
}

Polling for ended campaigns works as a fallback while the webhook is not registered yet, and afterwards as a periodic reconciliation job that catches missed events. Every few minutes is enough.

bash (polling · reconciliation)
# Campaigns scheduled in the last day that are now ENDED (from filters by scheduled time)
curl -s -G "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign" \
  -H "Authorization: Bearer YOUR_JWT" \
  --data-urlencode "campaignStatus=ENDED" \
  --data-urlencode "from=$(date -u -v-1d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --data-urlencode "count=20"

Previous ← Collect broadcast resultsNext → Mission 12 · Read statistics

Mission · Collect broadcast results

Mission 12 · Read statistics

Use the campaign statistics endpoint for one campaign's summary, and the date-range endpoint to fetch several campaigns at once.

GET/v2/{accessKey}/campaign/{campaignKeyOrId}/statsReference ↗
bash
curl -s "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign/CAMPAIGN_KEY/stats" \
  -H "Authorization: Bearer YOUR_JWT"

Statistics are available after the campaign ends. While it is live, or right after it ends before aggregation finishes, the API returns 404.

200 OK (example · some fields omitted)
{
  "campaignId": "123456",
  "campaignKey": "a1b2c3d4e5f6",
  "title": "Autumn New Arrivals Live",
  "campaignStartedAt": "2026-10-01T11:00:12Z",
  "campaignEndedAt":   "2026-10-01T12:03:40Z",
  "totalViewers": 8421,
  "totalLoginViewers": 5210,
  "newLoginViewers": 1830,
  "peakConcurrentViewers": 1320,
  "averageViewingTime": 412,
  "totalViewingTime": 3469452,
  "likes": 25630,
  "chats": 4180,
  "chatViewers": 910,
  "productClicks": 2760,
  "productClickViewers": 1540,
  "bannerClicks": 130
}
FieldDescription
totalViewersUnique viewers.
totalLoginViewersViewers with user info attached via the SDK. Counted only when your player passes user information.
newLoginViewersViewers who watched your campaigns for the first time.
peakConcurrentViewersPeak concurrent viewers.
averageViewingTime, totalViewingTimeAverage / total watch time in seconds.
likes, chats, productClicks, bannerClicksInteraction counts. The *Viewers variants are unique viewers who performed the action.
GET/v2/{accessKey}/campaign/stats?from=…&to=…&campaignKeys=…Reference ↗
bash
# Statistics for campaigns that ended in the last 7 days (max one week)
curl -s -G "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign/stats" \
  -H "Authorization: Bearer YOUR_JWT" \
  --data-urlencode "from=2026-09-25T00:00:00Z" \
  --data-urlencode "to=2026-10-02T00:00:00Z"

In the date-range query, from/to filter by the campaign's end time.

Previous ← Mission 11 · Detect the end via webhookNext → Mission 13 · Download logs

Mission · Collect broadcast results

Mission 13 · Download logs

When summary metrics are not enough, download event-level raw logs. Use ck for a single campaign's log, or from/to (max 1 week) for several campaigns.

GET/v2/{accessKey}/log?type=…&format=…&from=…&to=…&ck=…Reference ↗
bash
# Product-click log for one campaign, as CSV (with ck, the range from the campaign start time to 4 days after it is automatic)
curl -s -G "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/log" \
  -H "Authorization: Bearer YOUR_JWT" \
  --data-urlencode "type=productClickLog" \
  --data-urlencode "format=csv" \
  --data-urlencode "ck=CAMPAIGN_KEY" \
  -o product_clicks.csv
ParameterValues
typelikeLog · chatLog · productClickLog · popupClickLog · viewerAccessLog · viewerActivityLog · highlightedChatLog · filteredChatLog
formatjson · csv · tsv
from, toISO 8601. Required when querying without ck, and the range is at most 1 week.
ckRestrict to one campaign key or ID (optional). When set, the range from the campaign's start time to 4 days after it is applied instead of from/to. The campaign must have started.
excludeAdminDefault true. Excludes admin/operator activity from viewerAccessLog and viewerActivityLog. It does not filter chats.
Identifying viewers The user-identity columns in the logs are filled only for viewers whose user info was attached via the SDK. To match them to your members, first implement the user-identity step of the Web SDK guide.

Previous ← Mission 12 · Read statisticsNext → Mission 14 · Replay URL

Mission · Collect broadcast results

Mission 14 · Replay URL

If the campaign was created with serveReplay: true, the replay is produced automatically after the end. The Web SDK code from Your first broadcast plays the replay for an ended campaign as-is, so no extra work is needed there. For your own player or other channels, fetch the URL.

bash
# Replay URL of an ended campaign
curl -s "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign/CAMPAIGN_KEY" \
  -H "Authorization: Bearer YOUR_JWT" | jq '.stream.replayLiveUrl'
  • stream.replayLiveUrl — the replay stream URL. It may still be processing right after the end; if it is empty, query again a little later.
  • campaignMeta.shoplivePlayerUrl — a link that opens the replay in the Shoplive player, ready for sharing and push notifications.

Previous ← Mission 13 · Download logsNext → Error codes

Reference

Error codes

Codes you are most likely to meet, keyed by the _s value in the body. Treat a response as an error if the HTTP status is 400 or higher or _s is negative (see Error responses). Do not branch on the status code alone; check _s as well.

HTTP_sMeaningWhat to do
401-824Authentication failed (no token, bad signature, accessKey mismatch between URL and token), or External API v2 access is not enabled for the accountRun through the checklist in Authentication. If it persists with a valid token, ask your Shoplive account manager to enable API v2
401-412Token expiredGenerate a new token
400-510Bad request (missing required field, wrong format)Read _e
400-836 / -837Campaign create without campaignMeta / titleCheck the body
200—Product validation failure (missing name · url, negative price or discount rate). Not an HTTP error: it comes back as a per-item status: "FAIL" + message inside the bulk responseCheck failCount and results[].message
400-839Attaching products without productId or customerProductIdCheck the body
403-714 · -717 · -711 · -114 · -706Request not allowed in the campaign's current state (start on a live campaign -714 · start/end on an ended campaign -717 · end or log download on a campaign that has not started -711 · delete a started campaign -114 · delete the source of a rerun campaign -706)Check the campaign status (campaignStatus) and retry
404-710Campaign not foundCheck campaignKeyOrId
404-831Product not foundCheck productId / customerProductId
404-207Seller not foundCheck sellerId
404-404Statistics not available yet (live or still aggregating)Query again a few minutes later
406-1009Starting an auction for a sold-out productCheck the stock status
409-1002 · -1004 · -116Request conflicts with the current state (auction already running or ended, unlinking a popup that is showing, etc.)Check the state and retry
500—Server errorRetry; if it persists, contact support with the request time and the endpoint

Previous ← Mission 14 · Replay URLNext → Next steps

Reference

Next steps

With all three topics done, everything else you need for operations follows the same conventions in the reference.

GoalAPI
Rerun or duplicate a past campaignCampaign API rerun · duplicate
Manage brandsBrand API
Upload shortform videos and attach productsShortform API
Manage VOD videosVideo API
Coupon popups during a broadcastPopup API
Deeper web and app integrationWeb SDK guide · Mobile SDK guide

Previous ← Error codesNext → Troubleshooting & FAQ

Reference

Troubleshooting & FAQ

I generated a token but keep getting 401 Invalid token.

Check in order: ① the header is exactly Authorization: Bearer <token> (case and spacing); ② the payload contains an accessKey claim; ③ the {accessKey} in the path equals that claim; ④ outside Java, you signed with the Base64-decoded bytes of secretKey; ⑤ if everything matches and it still fails, ask your Shoplive account manager whether API v2 is enabled for your account. Pasting the token into jwt.io to eyeball the payload is the fastest check.

How often should I rotate the token?

It is a server-to-server credential, so there is no reason to keep it short-lived. Issue it for 1–3 years, store it in server configuration, and put a rotation step in your ops calendar before it expires; the API tells you with _s: -412 when it has. If a token leaks you must rotate secretKey, so never put it in frontend or app bundles.

Should I store campaignKey or campaignId?

API paths accept both. Player and SDK integrations and viewer URLs use campaignKey, so store campaignKey as the primary key and keep campaignId alongside it for matching statistics and logs.

I passed count=100 to a list endpoint but only got 20 items.

The campaign list returns up to 20 items per request (limits vary by API). Check totalCount and increase page to iterate. For bulk sync, split the period with from/to.

I re-sent a product registration (POST) and got FAIL.

The same customerProductId already exists. This is the intended duplicate protection. To change the product, use the PUT in Mission 7. POST, then PUT only the FAIL items: that pattern is your upsert.

Some items came back as FAIL when attaching products to a campaign.

Check the message field. When it says The product is already registered., the product is already attached. When it says The product does not exist., the product was never registered or the customerProductId has a typo. The endpoint is additive, so successful items are applied; set the order separately with PUT .../product/order.

I sent times with +09:00 but responses come back with Z.

Expected. Any ISO 8601 offset is accepted on input; storage and responses are normalized to UTC. Convert to your local time zone when displaying.

I called start and the status is ONAIR, but the player shows no video.

start only changes the status. Make sure your encoder is pushing to stream.streamUrl with stream.streamKey. The usual order is: start the encoder, confirm the preview in the console, then call start.

Statistics right after the end return 404, or are small or zero.

Aggregation can take a few minutes to finish after the end. When the webhook end event arrives, schedule the fetch a few minutes later instead of calling immediately, or re-query until the values stop changing.

Still stuck? Send the request time (UTC), the endpoint you called, and the _s·_e values from the response to your Shoplive account manager or the technical support channel. Never include the token or secretKey itself.