Samsung SmartThings
What is SmartThings
SmartThings is the Samsung Smart Home integration that allows users to control their RainMaker devices via the Samsung SmartThings app. It implements the SmartThings Schema (cloud-to-cloud) connector, handling device discovery, state refresh, device commands, and proactive state callbacks.
Why it is needed
To enable Samsung SmartThings app control for RainMaker devices. Users link their RainMaker account with SmartThings via OAuth against the ESP User OIDC identity provider and can then discover, control and monitor their devices from the SmartThings app. SmartThings also receives proactive state callbacks when device state changes physically, keeping the SmartThings app in sync.
Pre-requisites
User is registered and authenticated
SmartThings Developer Center account with an Organization
SmartThings configuration stored via the Config API
Architecture
Two Lambda functions serve the SmartThings integration:
st_action— Schema App Lambda handling all SmartThings Schema interactions (discovery, state refresh, commands, callback token management). Invoked directly by the SmartThings cloud (not via API Gateway) via a resource-based policy granting the SmartThings platform account (148790070172) invoke permission.st_cfg— Configuration API for managing SmartThings credentials (Client ID/Secret in SSM) and registering the SmartThings OAuth callback URLs on the voice-assistant OIDC client. Invoked via API Gateway (deployed with the corermngstacks).
The Schema App is deployed multi-region by the standalone CDK app cdk/apps/smartthings.py (Stackfile group smartthings, make deploy-smartthings): one rmng-st-core-<region> stack in each SmartThings geo — us-east-1 (North America), eu-west-1 (Europe) and ap-northeast-1 (Asia Pacific) — each deploying the Lambda rmng-st-action-<rmng_region> and exporting its ARN as STSchemaAppFunctionArn.
Configuration
OIDC VA Client
Account linking runs against the ESP User OIDC identity provider, not Cognito. A confidential OAuth 2.1 client (va-client) is seeded into the ESP User client registry (espuser-oauth-clients) for voice-assistant account linking:
OAuth flow: Authorization Code Grant (
grant_types:authorization_code,refresh_token)Scopes:
openid,email,phone,profileRedirect URIs: registered dynamically by the config API (there are no hardcoded initial values); each POST unions its URIs onto the client’s existing set, so the SmartThings, Alexa and Google Voice redirect URIs coexist on the shared
va-clientrow. The SmartThings config API registers the three fixed SmartThings callback URLs (https://c2c-us.smartthings.com/oauth/callback,https://c2c-eu.smartthings.com/oauth/callback,https://c2c-ap.smartthings.com/oauth/callback) — they are not part of the request body.Client ID:
va-client— the registry client id (also available in SSM/espuser/base/va-client-id)Client Secret: the generated secret for
va-client, retrievable via the admin clients API (GET /v1/admin/clients?get_secret=true) or SSM/espuser/base/va-client-secretOIDC endpoints: the
authorization_endpointandtoken_endpointpublished in the discovery document (/.well-known/openid-configuration). Both are served on the ESP User API Gateway base (EspUserApiUrl):<api-url>/oauth2/authorizeand<api-url>/oauth2/token.
SmartThings Developer Center Setup
This must be done before calling the Store Configuration API.
Step 1: Create a Schema Cloud Connector
Click Device Integrations in the left sidebar
Create a Product and add a Cloud Connector to it → ST Schema. The Product is the container the connector lives in, and adding the Schema App to it links the two automatically — this is not a device profile, and nothing per-device-type is created here (see Step 5).
Fill in the App Name (e.g., “RMNG Smart Home”)
App Icon: use
assets/smartthings_logo.png(the Neo logo, 512×512 PNG). SmartThings uses this as the icon shown during account linking (and for the 2x/3x icon URLs).
Step 2: Configure Target ARN
The Schema App Lambda is deployed by the standalone rmng-st-core stack into each SmartThings region. In rmng-outputs.json the stack is keyed rmng-st-core-<rmng-region> and each SmartThings region holds its own STSchemaAppFunctionArn under regions.
Field |
Value |
|---|---|
North America |
|
Europe |
|
Asia Pacific |
|
At least one region is required.
Note: You would need this Lambda deployed in all 3 regions to support users from all locations. If any region is unavailable, the users from there won’t be able to link their account.
Step 3: Configure Device Cloud Credentials (OAuth)
Field |
Value |
Source |
|---|---|---|
Client ID |
|
The OIDC client id (SSM |
Client Secret |
The |
Admin clients API ( |
OAuth URL |
|
The discovery document’s |
OAuth Scope |
|
— |
Token URL |
|
The discovery document’s |
Alert Notification Email |
Your platform operator email |
— |
Step 4: Save and Store SmartThings Credentials
After saving, SmartThings provides you with:
SmartThings Client ID — used by the Schema App to authenticate callback requests
SmartThings Client Secret — used with the Client ID for callback authentication
Store these via the Config API:
curl -X POST https://<ApiGatewayUrl>/v1/admin/integrations/smartthings/configuration -H "Content-Type: application/json" -d '{"client_id": "<SmartThings Client ID>", "client_secret": "<SmartThings Client Secret>"}'
(The request must be authenticated as a admin — SigV4-signed like the other admin configuration APIs.)
Step 5: Device Handler Types
A Schema App picks each device’s presentation from the deviceHandlerType in the discovery
response. Every type we emit is one of the pre-made c2c-* handlers, which SmartThings
provides —
nothing has to be authored for them, and a connector discovers and controls devices with no
Device Profile of its own.
A custom Device Profile is needed only when no pre-made handler covers the capabilities; its
profile ID then goes in deviceHandlerType in place of a c2c-* name. The Product created in
Step 1 is just the connector’s container — it carries
no per-device-type configuration of its own.
getDeviceHandlerType in src/smartthings/handle_discovery.go derives the type from the node’s
parameter types, so this table is the mapping that code implements.
Device Type |
Handler Type |
Capabilities |
|---|---|---|
Light (on/off only) |
|
Switch, Health Check |
Dimmable Light |
|
Switch, Switch Level, Health Check |
RGB Light |
|
Switch, Switch Level, Color Control, Health Check |
CCT Light |
|
Switch, Switch Level, Color Temperature, Health Check |
RGBW Light |
|
Switch, Switch Level, Color Control, Color Temperature, Health Check |
Fan |
|
Switch, Fan Speed, Health Check |
Thermostat |
|
Thermostat Mode, Thermostat Heating Setpoint, Health Check |
Switch |
|
Switch, Health Check |
Step 6: Test with Schema Invitations
Before publishing to the SmartThings catalog:
Generate an invitation with the SmartThings CLI —
smartthings invites:schema:create, which prompts for a description and an accept limit. Invitations are created through the CLI, not the connector settings page.Share the resulting Accept URL (
https://invitations.smartthings.com/schemaApp/<INVITATION_ID>) with test usersTest users open the link on their phone, choose a Location, authorize, and sign in with their RainMaker credentials
Monitor
interactionResultCloudWatch logs for errors reported by SmartThings
An invitation is how testers other than the connector’s owner install it — My Testing Devices in the app only lists connectors owned by that Samsung account. Two consequences for what an invitation can verify: app-to-app linking cannot be exercised this way, and device onboarding runs automatically, so the onboarding flow is not testable either.
Re-installing after unlinking uses the same Accept URL — the invitation stays valid and reusable until one of three things happens: it passes its automatic 30-day expiry, its accept limit is exhausted (default 500 installs), or the developer deletes it. In any of those cases nobody can reinstall until a new invitation is generated, which can be done at any time.
Step 7: WWST Certification (Production)
For production catalog listing:
Complete end-to-end testing using Schema Invitations
Ensure all interaction types pass SmartThings validation
Submit for review
SmartThings Config API
Store Configuration
API: POST /v1/admin/integrations/smartthings/configuration
Authorization: Admin only (SigV4); other callers receive 403.
Request:
{
"client_id": "<SmartThings Client ID>",
"client_secret": "<SmartThings Client Secret>"
}
Validation:
client_idmust be non-empty and ≤256 charactersclient_secretmust be non-empty and ≤256 charactersValidation failures return
400with{"message": "<what failed>", "field": "<field name>"}
Process:
Validate admin authorization
Validate input fields
Register the three SmartThings callback URLs on the OIDC
va-clientregistry row, unioning them onto its existing set (env var:OIDC_VA_CLIENT_ID, valueva-client)Store Client ID at SSM
/rmng/smartthings/client_id(String)Store Client Secret at SSM
/rmng/smartthings/client_secret(SecureString)
Response:
{
"message": "SmartThings configuration stored successfully"
}
Get Configuration
API: GET /v1/admin/integrations/smartthings/configuration
Response (the client secret is never returned):
{
"client_id": "<SmartThings Client ID>"
}
Delete Configuration
API: DELETE /v1/admin/integrations/smartthings/configuration
Process:
Delete
/rmng/smartthings/client_idand/rmng/smartthings/client_secretfrom SSM
Response:
{
"message": "SmartThings configuration deleted successfully"
}
Any HTTP method other than GET/POST/DELETE returns 405.
Per-Node Enable/Disable
Each node has an st_en service-data key in the node details table indicating SmartThings is enabled for it — the same pattern as Alexa (alexa_en/getAlexaEn) and GVA (gva_en/getGVAEn).
Default:
false(disabled)Auto-enable on discovery: when a
discoveryRequestreturns a node’s devices, the Schema App setsst_en: trueand pushes the status to the device via thegetSTEnnode event ({"event": ["getSTEn"], "data": {"getSTEn": {"enabled": true}}}). Discovery is not filtered byst_en; every qualifying device is returned and enabled.What the flag drives: once
st_enis true, the device firmware includes"smartthings"in thenotifymap of its shadow updates, which is what triggers proactive state callbacks (see below). A node withst_en: falsesimply never emits SmartThings notifications.
Schema Interactions
Authentication
SmartThings sends an ESP User access token (issued to the voice-assistant client at account linking) in request.authentication.token. The Lambda validates it and extracts the user ID. For invalid/expired/missing tokens, the response includes isAuthenticated: false.
Interactions that require authentication: discoveryRequest, stateRefreshRequest, commandRequest
Interactions that do not require authentication: grantCallbackAccess (validated separately via the code exchange), integrationDeleted, interactionResult
Device ID Format
Devices are identified as <nodeID>#<deviceName> (e.g., ABC123#Light). The separator is # because it cannot appear in a node ID, so the node is always recoverable even when the device name contains one. This composite ID maps a SmartThings device back to a specific RainMaker node and device within that node.
Interaction Types
Interaction Type |
Handler |
Description |
|---|---|---|
|
|
Device discovery |
|
|
Current state query |
|
|
Device control |
|
|
Store callback tokens |
|
|
Remove callback tokens |
|
|
Log errors from SmartThings |
Capability Mapping
RainMaker Param Type |
SmartThings Capability |
Attribute |
Value Range |
|---|---|---|---|
|
|
|
on/off |
|
|
|
0-100 |
|
|
|
0-360 |
|
|
|
0-100 |
|
|
|
2200-6500 K |
|
|
|
0-100 |
|
|
|
0-100 °C |
(always included) |
|
|
online/offline |
Units are per-attribute, not per-value. Every state is validated against its capability schema, and an attribute that declares no unit is rejected if one is sent — the whole response fails with BAD-RESPONSE "should NOT have additional properties 'unit' for attribute '<attr>' in capability '<cap>'", so one stray unit takes out every device in the payload. Send unit only where the capability declares it:
Attribute |
|
|---|---|
|
|
|
|
|
|
|
none |
|
none |
Proactive State Callbacks
Dispatcher integration
State callbacks are delivered through the shared notifications dispatcher. Specifically:
Registered service name:
smartthingsService kind: user-specific. The dispatcher resolves the user list for the originating group/subgroup before fanning out per-user.
Trigger: a shadow update where the dispatch event’s
notifymap contains a"smartthings"key. Direct notifications (notify/…topics) are not supported by the SmartThings channel — onlyshadow_updateevents are marshalled.SmartThings is a bespoke adapter — it owns the per-user callback URL, the ST Schema token-refresh flow, and the
stateCallbackenvelope shape, rather than reusing the generic webhook scaffold. See notifications-webhooks.md for context on the two implementation patterns.
Obtaining callback tokens (grantCallbackAccess)
After a user links their account, SmartThings sends a grantCallbackAccess interaction containing an authorization code, the SmartThings clientId, and the callback URLs (oauthToken, stateCallback). The Schema App exchanges the code for callback access/refresh tokens by POSTing an accessTokenRequest to the oauthToken URL, authenticating with the clientId plus the Client Secret read from SSM (/rmng/smartthings/client_secret) — grantCallbackAccess itself does not include the secret. The resulting tokens are stored per-user in the rmng-user-endpoints table as a smartthings integration row (see Token storage).
SmartThings sends
grantCallbackAccessautomatically as part of account linking.requestGrantCallbackAccess: trueon adiscoveryResponseis only for re-requesting tokens after a refresh failure — do not set it during normal discovery, or SmartThings will reject the link.
Token lifetime and refresh (refreshAccessTokens)
The callback access token SmartThings issues lives 24 hours. access_expires_at is stored alongside it, and the next state callback past that point refreshes before sending.
The refresh is a different interaction type from the initial exchange, though both POST to the same oauthToken URL:
Interaction |
|
|
Credential sent |
|---|---|---|---|
Initial exchange, after |
|
|
|
Refresh of an expired token |
|
|
|
Both carry the clientId and the Client Secret from SSM. SmartThings admits only the pairing above and answers 400 with globalError.errorEnum: "UNSUPPORTED-GRANT-TYPE" for any other combination — notably for a refresh_token grant sent under accessTokenRequest.
Getting this wrong fails in a way that is easy to misread: linking works, callbacks work, and then the channel goes silent exactly 24 hours later when the first refresh is attempted, with the devices in the app frozen on their last reported state. test_smartthings_state_callback_refreshes_expired_token covers it by backdating access_expires_at — the refresh path is otherwise unreachable inside a test run.
Token storage
Per-user SmartThings callback credentials live in the same rmng-user-endpoints DynamoDB table that backs push registration, keyed by user and endpoint:
Column |
Value for SmartThings |
|---|---|
|
partition key, the user identity |
|
sort key, |
|
|
|
the |
|
nested map: |
|
the |
SmartThings sends both URLs per region, so the stored values differ by where the user linked from:
Region |
|
|---|---|
North America |
|
Europe |
|
Asia Pacific |
|
Both arrive in the grantCallbackAccess payload and are stored per user, so no path is hardcoded — only the three OAuth redirect URIs registered by the config API are (https://c2c-<region>.smartthings.com/oauth/callback, see st_cfg_main.go).
On integrationDeleted, the user’s smartthings rows are removed.
Sending state callbacks
When a device shadow updates (and the device emits "smartthings" in its notify map):
The shadow update triggers the notifications dispatcher
The SmartThings notification service marshals the update to SmartThings capability states. Which devices are reported depends on what moved:
Params changed — only the devices present in the delta are reported, each with its mapped capability states.
Connectivity only (a delta carrying
onlinewith noparams, which is what a connect or disconnect produces) — every discoverable device on the node is reported withst.healthCheckalone. Without this a node could be online for hours and still read offline in the app, since nothing else pushes reachability and SmartThings pollsstateRefreshonly on its own schedule.Neither — the marshal step returns an empty result and the callback is skipped.
For each target user, for each stored callback endpoint:
Check token expiry → refresh via
refreshAccessTokensagainst the row’stoken_callback_urlif needed (requiresssm:GetParameteron/rmng/smartthings/*for the client credentials)POST the state callback to the user’s stored
stateCallbackURL
Always includes
st.healthCheckwith connectivity status (online/offline)
A device that reports online before it knows its integration flags defeats all of this: update_shadows attaches the notify map only for integrations already enabled, so the one update carrying the transition to online reaches the dispatcher with no notify services and is dropped at the front door. A device (or simulator) must apply the getSTEn/getAlexaEn/getGVAEn responses before writing its presence — they arrive in the same from_cloud response as getGroupInfo, so ordering within that handler is what decides it.
The callback body must be a full ST Schema envelope — headers (with interactionType: "stateCallback", schema: "st-schema", version: "1.0", requestId), authentication (the user’s callback access token as a Bearer token), and deviceState. Omitting the headers/authentication envelope causes SmartThings to reject the callback with BAD-REQUEST "Invalid or unspecified schema".
Error semantics
The SmartThings channel is best-effort:
A user with no stored callback tokens is skipped and logged — other users still get their callback.
A failed token refresh or a non-2xx response for one (user, endpoint) pair is logged and skipped — the loop continues.
The dispatcher only sees a hard failure if the marshal step itself fails, in which case
smartthingsis dropped for that event but other channels (push, Alexa, GVA, …) still fire.
There is no retry, no DLQ, and no delivery receipt — observability comes from logs and the interactionResult errors SmartThings reports back to the Schema App.
Adding Support for New Device Types
When a new RMNG device type needs SmartThings support, changes are required in both the codebase and the SmartThings Developer Center.
Code Changes
Add param type constant in
src/smartthings/utils.go:ParamTypeNewParam = "esp.param.new-param"
Update
GetSTCapabilitiesinsrc/smartthings/utils.go— add a case mapping the new param type to the SmartThings capability:case ParamTypeNewParam: capabilitySet[CapabilityNewCapability] = true
Add capability constant in
src/smartthings/types.go:CapabilityNewCapability = "st.newCapability"
Update
getDeviceHandlerTypeinsrc/smartthings/handle_discovery.go— add logic to return the correct handler type when the new capability is present.Update
mapShadowToSTStatesinsrc/smartthings/handle_state_refresh.go— add a case to map the shadow value to the SmartThings attribute format.Add command handler in
src/smartthings/handle_command.go— add a case inexecuteSingleCommandand implement the command-to-MQTT mapping function.Update tests — add test cases for the new capability in unit tests.
Build and deploy:
make deploy-smartthings
SmartThings Console Changes
Usually none: pick a handler type from the table above that covers
the new capability and the app renders it. Only if no pre-made c2c-* handler covers it do you
create a custom Device Profile in the Workspace and return its profile ID as deviceHandlerType.
Capability Reference
SmartThings capabilities are documented at: https://developer.smartthings.com/docs/devices/capabilities/capabilities-reference
Common capabilities for IoT devices:
st.switch— on/off controlst.switchLevel— dimming (0-100)st.colorControl— hue/saturationst.colorTemperature— CCT in Kelvinst.fanSpeed— fan speed percentagest.thermostatMode— heating/cooling modest.thermostatHeatingSetpoint— temperature setpointst.healthCheck— device connectivity (always included)st.doorControl— open/closest.lock— lock/unlockst.motionSensor— motion detectedst.contactSensor— open/closed
Testing
Until the connector is WWST-certified it is a test integration, so Developer Mode is required in the SmartThings app to see it — the same idea as an Alexa beta invite or a Google Home test action.
Important: Add at least one device to the RainMaker account before linking, otherwise SmartThings links against an empty home and you will need to unlink and relink later.
Enable Developer Mode
Open the SmartThings app (logged in with your Samsung account)
Go to Menu / Settings → tap About SmartThings
Long-press on the “About SmartThings” row for 5–10 seconds
Toggle Developer Mode to ON when the hidden menu appears
Force-close and restart the app to flush cached metadata
Link Your Test Integration
In the app, go to Devices tab → tap + (Add Device)
Select Partner Devices (or “By Brand”)
Look at the top of the list for My Testing Devices (only visible with Developer Mode active)
Tap your Schema App integration (e.g., “RMNG Smart Home”)
Select the Setup App row under “My setup apps” first (ensures the OAuth account-linking handshake executes correctly)
Sign in with your RainMaker credentials via the ESP User OIDC sign-in page
After successful OAuth, SmartThings sends
grantCallbackAccessanddiscoveryRequestto the LambdaYour RainMaker devices appear in the app (typically within a few seconds), and state changes made from the device or the RainMaker app are reflected back in SmartThings
Re-trigger Discovery
To refresh the device list after adding new nodes (or to recover from a failed link):
Remove the integration: Menu (☰) → ⚙ Settings → Linked services → tap the connector → Unlink. This also removes the devices and Routines it contributed.
Re-add it — via My Testing Devices if the connector is owned by your own Samsung account, or by re-opening the invitation’s Accept URL if you were invited as a tester (Schema Invitations). Either repeats the OAuth + discovery flow.
Removing and re-adding is the only way to obtain fresh callback tokens. SmartThings sends grantCallbackAccess on a new authorization, not on reopening the app, signing in again, or re-running discovery — so a connector whose stored tokens have gone bad stays broken until it is unlinked. integrationDeleted deletes the user’s stored rows on the way out, and the re-link writes a new one.
Checkpoint: rmng-st-action-<rmng_region> logs show integrationDeleted, then grantCallbackAccess, then discoveryRequest; the rmng-user-endpoints row for smartthings#<endpoint> carries an access_expires_at 24 hours out.
End-to-End Verification Walkthrough
Ordered checklist for a developer verifying the whole pipeline on a fresh deployment, with the observable checkpoint at each hop. Prerequisite: a deployed espuser + rmng stack group and AWS credentials for that account.
Deploy the Schema App stacks. First time only, bootstrap the three SmartThings regions, then deploy:
./scripts/deploy.sh --setup --stack-group smartthings make deploy-smartthings
Checkpoint:
rmng-st-core-<rmng_region>exists inus-east-1,eu-west-1andap-northeast-1, andrmng-outputs.json(regenerated automatically after the deploy) containsSTSchemaAppFunctionArnunder aregionsmap for each.Create the connector in the SmartThings Developer Center (Developer Center Setup, steps 1–3): register the three Lambda ARNs from step 1 as target ARNs and copy the OAuth URLs from the deployment’s OIDC discovery document.
Store the SmartThings credentials via the Config API with a admin account. This also registers the three
c2c-*.smartthings.comcallback URLs on the OIDCva-clientrow.Checkpoint:
GET /v1/admin/integrations/smartthings/configurationreturns theclient_id; theespuser-oauth-clientsrow forva-clientlists the three redirect URIs.Bring up a test user and device. Use
morpheusto create a user and register a node (seecli/README.md), then run the device simulator:morpheus device-sim <device-id-from-test_config.json>
Checkpoint: the simulator boots and requests
getSTEn; before discovery it printsSmartThings enabled status updated: False.Link the integration in the SmartThings app (Link Your Test Integration), signing in as the user from step 4.
Checkpoint: CloudWatch logs of
rmng-st-action-<rmng_region>(in the region SmartThings picked for the account’s geo) show agrantCallbackAccessfollowed by adiscoveryRequest; the devices appear in the app; the simulator printsSmartThings enabled status updated: True(discovery auto-enablesst_en).Control: SmartThings → device. Toggle the device in the app.
Checkpoint: the simulator prints the received parameter update; the app reflects the commanded state from the
commandRequestresponse.State callback: device → SmartThings. Change a parameter from the simulator’s interactive prompt (or the RainMaker app).
Checkpoint: the simulator’s shadow update carries
notify: {smartthings: true, version: N};rmng-notificationslogs show the SmartThings service dispatching astateCallback; the tile in the SmartThings app updates within a few seconds.Run the integration tests against the deployment:
make itest ITEST_ARGS=test/itest/test_smartthings.py
This covers config-API CRUD plus discovery/command/state-refresh by invoking each regional Schema App Lambda directly. The Schema tests skip with
No rmng-st-core regions in rmng-outputs.jsonif step 1 was missed.