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.
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.
Prerequisites
- accessKey / secretKey — an API key pair issued per customer account. Find it in Shoplive Admin or ask your Shoplive account manager. Keep
secretKeyon 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
| Environment | Base URL | Notes |
|---|---|---|
| Production | https://private.shopliveapi.com | There 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)
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
| Item | Value | Description |
|---|---|---|
| Algorithm | HS256 | The signing key is your secretKey. |
Claim accessKey | Required | Your customer accessKey. Sign with the secretKey of the same account. |
Claim exp | Required | Expiry. This is a server-to-server credential, so a long lifetime (1–3 years) reused from server config is fine. |
Claim iat | Recommended | Issued-at time. |
| Header | Authorization: Bearer {JWT} | Send on every request. |
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.
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:
{ "_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 asAuthorization: Bearer <token>.Invalid token— signature mismatch (wrong secretKey or missing Base64 handling), missingaccessKeyclaim, or an unknown accessKey.Expired token(_s: -412) —exphas passed. Generate a new token._s: -824even though the token is valid — check that the accessKey in the URL matches the token'saccessKeyclaim. 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
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/jsonwith 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:00or2026-10-01T11:00:00Z. Responses are normalized to UTC (Z). - Success is HTTP
200or204. Endpoints with nothing to return (start / end broadcast, etc.) respond with an empty body.
Identifiers
| Identifier | Description |
|---|---|
accessKey | Your customer key. First path segment of every endpoint. |
campaignKey / campaignId | Campaign identifiers. Wherever a path says {campaignKeyOrId}, either one works. Player and SDK integrations use campaignKey. |
productId / customerProductId | Product identifiers. customerProductId is your own product ID; set it on creation and it becomes the key for de-duplication, updates and attaching. |
sellerId, brandId | Seller 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.
| Parameter | Default | Description |
|---|---|---|
page | 1 | 1-based page number. |
count | 10 | Page 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. |
sortAscending | false | Sort direction. Campaign lists sort by scheduled time. |
{
"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.
{
"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.
{
"_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.
Previous ← AuthenticationNext → Your first broadcast
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.
- Mission 1 · Create a campaign — create it with a title and schedule, and receive a
campaignKey. - Mission 2 · Register products — register products under your own product IDs (
customerProductId). - Mission 3 · Attach products to the campaign — attach them to the campaign and set the order.
- Mission 4 · Get stream info — RTMP URL, stream key and player URL.
- Mission 5 · Show the player on your page — a few lines of Web SDK code.
- Mission 6 · (Optional) Start & end via API — when you need automation instead of the console.
Previous ← ConventionsNext → Mission 1 · Create a campaign
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.
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"
}
}'| Field | Required | Description |
|---|---|---|
campaignMeta.title | Required | Campaign title. Missing → _s: -837. |
campaignMeta.description | Internal memo. | |
campaignMeta.tags | Array of tags for search and grouping. | |
campaignMeta.serveReplay | Whether to publish the replay after the broadcast ends. | |
campaignMeta.scheduledStartAt / scheduledEndAt | Scheduled times, shown in the player's "coming up" state. | |
campaignMeta.rehearsalPasscode | Passcode for the rehearsal. | |
seller.sellerId, campaignMeta.brand.brandId | Set these to create the campaign under a seller or brand. |
{
"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)
| Status | Meaning |
|---|---|
RESERVED | Scheduled to start automatically. |
READY | Can be started. Default right after creation; the player shows the "coming up" screen. |
REHEARSAL | Rehearsal in progress; only people with the passcode (rehearsalPasscode) can watch. Returns to READY when ended with end. |
ONAIR | Live. |
ENDED | Finished. Replay is served if serveReplay is true. |
Previous ← Your first broadcastNext → Mission 2 · Register products
Mission 2 · Register products
Register products once per account and reuse them across campaigns. Send an array to register several at a time.
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
}
]'| Field | Required | Description |
|---|---|---|
customerProductId | Recommended | Your 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. |
name | Required | Product name. |
url | Required | Product detail page. Where viewers land when they tap the product in the player. |
imageUrl | Recommended | Thumbnail image. |
currency, originalPrice, discountPrice, discountRate | Pricing. | |
showPrice | Whether to show the price (default true). Include it in updates (PUT) as well. | |
brand, brandIdentifier | Brand display name and your brand code. | |
sku, description, ctaButtonTitle | Extra details. Use ctaButtonTitle to change the button label (contact Shoplive before using it). | |
seller.sellerId | Set to create the product under a seller. Omit for a customer-owned product. |
{
"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 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.
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" } ]'| Field | Description |
|---|---|
customerProductId or productId | The product to attach. Use one kind per request. |
{
"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.
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 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.
curl -s "https://private.shopliveapi.com/v2/YOUR_ACCESS_KEY/campaign/CAMPAIGN_KEY" \
-H "Authorization: Bearer YOUR_JWT"| Path | Use |
|---|---|
stream.streamUrl + stream.streamKey | Enter these as the server URL and stream key in OBS or your encoder. Once streaming, the console preview shows video. |
campaignMeta.shoplivePlayerUrl | A ready-to-open viewer page. |
campaignMeta.campaignKey | The value you pass to the Web SDK in the next step. |
stream.liveUrl | HLS (m3u8) URL for your own player. |
campaignProductList[] | Products attached in Mission 3, with sortOrder. |
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 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.
<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
accessKeyandcampaignKey. - Broadcast list page — a common pattern is
GET /v2/{accessKey}/campaign?campaignStatus=READYto fetch upcoming campaigns (if you scheduled auto start for a saved-video campaign in the admin, it isRESERVED; 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 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.
# 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.
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
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.
Previous ← Mission 6 · (Optional) Start & end via APINext → Mission 7 · Product upsert pattern
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.
- Send every changed product with POST. New ones come back as
CREATE, existing ones asFAIL. - Collect the items that came back
FAILand send them again with PUT. Items with no changes also respondUPDATE. Themessagetext depends on the language setting, so don't match on the string. - 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 asFAIL. The default (true) removes the product from campaign product lists and deletes it.
# 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 }
]'# 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
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 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.
| Operation | Endpoint | Notes |
|---|---|---|
| Attach | POST /v2/{accessKey}/campaign/{campaignKeyOrId}/product | Appends. Already-attached items come back as FAIL. |
| Detach | DELETE /v2/{accessKey}/campaign/{campaignKeyOrId}/product | Removes from the campaign only; the product itself stays. |
| Reorder | PUT /v2/{accessKey}/campaign/{campaignKeyOrId}/product/order | Sorted in the order listed; unlisted items move to the end. |
| Detach all | DELETE /v2/{accessKey}/campaign/{campaignKeyOrId}/product/all | Empty the list and rebuild it. |
# 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 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.
# 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" } ]'stockStatus | Shown as |
|---|---|
IN_STOCK | Available. |
LOW_IN_STOCK | Low stock indicator. |
SOLD_OUT | Sold out. The purchase button is disabled. |
ONAIR keeps the call volume down.Previous ← Mission 8 · Update campaign productsNext → Mission 10 · Split by seller
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.
| Goal | How |
|---|---|
| Create and look up sellers | Seller 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 seller | PUT /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 items | sellerId=123 on the campaign or product list API |
Previous ← Mission 9 · Live stock & sold-outNext → Collect broadcast results
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.
Previous ← Mission 10 · Split by sellerNext → Mission 11 · Detect the end via webhook
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.
| Event | Use |
|---|---|
| Campaign start | Turn on a "LIVE" badge on your page, send push notifications |
| Campaign end | The trigger for this topic. Collect statistics and logs, publish the replay |
| Auction start · end · reset | Settle 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) andX-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.
# 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 12 · Read statistics
Use the campaign statistics endpoint for one campaign's summary, and the date-range endpoint to fetch several campaigns at once.
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.
{
"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
}| Field | Description |
|---|---|
totalViewers | Unique viewers. |
totalLoginViewers | Viewers with user info attached via the SDK. Counted only when your player passes user information. |
newLoginViewers | Viewers who watched your campaigns for the first time. |
peakConcurrentViewers | Peak concurrent viewers. |
averageViewingTime, totalViewingTime | Average / total watch time in seconds. |
likes, chats, productClicks, bannerClicks | Interaction counts. The *Viewers variants are unique viewers who performed the action. |
# 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 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.
# 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| Parameter | Values |
|---|---|
type | likeLog · chatLog · productClickLog · popupClickLog · viewerAccessLog · viewerActivityLog · highlightedChatLog · filteredChatLog |
format | json · csv · tsv |
from, to | ISO 8601. Required when querying without ck, and the range is at most 1 week. |
ck | Restrict 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. |
excludeAdmin | Default true. Excludes admin/operator activity from viewerAccessLog and viewerActivityLog. It does not filter chats. |
Previous ← Mission 12 · Read statisticsNext → Mission 14 · Replay URL
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.
# 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
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 | _s | Meaning | What to do |
|---|---|---|---|
| 401 | -824 | Authentication failed (no token, bad signature, accessKey mismatch between URL and token), or External API v2 access is not enabled for the account | Run through the checklist in Authentication. If it persists with a valid token, ask your Shoplive account manager to enable API v2 |
| 401 | -412 | Token expired | Generate a new token |
| 400 | -510 | Bad request (missing required field, wrong format) | Read _e |
| 400 | -836 / -837 | Campaign create without campaignMeta / title | Check 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 response | Check failCount and results[].message |
| 400 | -839 | Attaching products without productId or customerProductId | Check the body |
| 403 | -714 · -717 · -711 · -114 · -706 | Request 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 | -710 | Campaign not found | Check campaignKeyOrId |
| 404 | -831 | Product not found | Check productId / customerProductId |
| 404 | -207 | Seller not found | Check sellerId |
| 404 | -404 | Statistics not available yet (live or still aggregating) | Query again a few minutes later |
| 406 | -1009 | Starting an auction for a sold-out product | Check the stock status |
| 409 | -1002 · -1004 · -116 | Request conflicts with the current state (auction already running or ended, unlinking a popup that is showing, etc.) | Check the state and retry |
| 500 | — | Server error | Retry; if it persists, contact support with the request time and the endpoint |
Previous ← Mission 14 · Replay URLNext → Next steps
Next steps
With all three topics done, everything else you need for operations follows the same conventions in the reference.
| Goal | API |
|---|---|
| Rerun or duplicate a past campaign | Campaign API rerun · duplicate |
| Manage brands | Brand API |
| Upload shortform videos and attach products | Shortform API |
| Manage VOD videos | Video API |
| Coupon popups during a broadcast | Popup API |
| Deeper web and app integration | Web SDK guide · Mobile SDK guide |
Previous ← Error codesNext → Troubleshooting & FAQ
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.
_s·_e values from the response to your Shoplive account manager or the technical support channel. Never include the token or secretKey itself.