Build a Home Assistant stock price sensor with its built-in RESTful integration, a SiftingIO API key and a few YAML blocks. Create an API key, then check that your account can access US stock data before configuring the sensor.
This guide adds a price card, a market-open gate and a timestamp-based age label. The polling budget is worked out below so you can choose a sensible refresh interval. Prices are for reference and may differ from a broker's executable quote; see the data methodology and delayed vs live prices for those distinctions.
The two endpoints#
The price comes from the last-trade snapshot endpoint. Venue is stocks, the symbol is a plain US ticker, and authentication is the X-API-Key header:
curl -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/last/trade/stocks/AAPL"
The documented response has four fields: s (symbol), p (price, a string), P (size, a string) and t (a Unix epoch timestamp in milliseconds, an int64). A fixture in that shape, chosen to be internally consistent and not the output of a real call:
{ "s": "AAPL", "p": "231.42", "P": "100", "t": 1790777400000 }
The template checks that p is numeric before converting it with | float. Home Assistant exposes entity states as text even for numeric sensors; the conversion makes the input explicit and avoids treating an API error as a price. For age calculations, divide t by 1000 to convert milliseconds to seconds.
Keep the key in the header, not in a URL that can be copied into browser history or logs. If a proxy strips the header, fix the proxy configuration rather than putting the key in the query string.
The market-status endpoint supplies the polling gate:
curl -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/fnd/markets/us_equities/status"
The documented response wraps the status in data: data.is_open is a boolean, data.state is closed, regular or open, data.next_open and data.next_close are RFC 3339 UTC timestamps, and data.timezone is an IANA zone (America/New_York for this market). Use is_open for the gate. next_open and next_close are independent upcoming transitions, not the start and end of one session: while trading is open, the next close can occur before the next open. The regular session is 09:30–16:00 in America/New_York, with holidays and early closes handled separately. See market hours.
Keep the key out of configuration.yaml#
Put the key in secrets.yaml in the same folder and reference it with !secret. This keeps the key separate from the example configuration, but does not encrypt it. Protect both the file and its backups.
# secrets.yaml
sifting_key: "sft_your_key_here"
The market-open gate#
This is the gate. It polls the status endpoint on its own scan_interval all day, because it has to notice the open on its own. Fifteen minutes is used below; the budget section explains why.
Merge these entries into your existing rest:, automation: and template: sections rather than creating duplicate top-level keys. If your configuration uses automation: !include automations.yaml, put only the list item from the automation example in that file. After loading the configuration, check the actual entity IDs in Home Assistant and use those IDs in the automation and card.
# configuration.yaml
rest:
- resource: https://api.sifting.io/v1/fnd/markets/us_equities/status
headers:
X-API-Key: !secret sifting_key
scan_interval: 900
binary_sensor:
- name: "US stock market open"
unique_id: sifting_us_equities_open
value_template: "{{ value_json.data.is_open }}"
availability: >-
{{ value_json is defined and value_json.data is defined
and value_json.data.is_open is defined }}
sensor:
- name: "US stock market session"
unique_id: sifting_us_equities_session
value_template: "{{ value_json.data.state }}"
availability: >-
{{ value_json is defined and value_json.data is defined
and value_json.data.state is defined }}
json_attributes_path: "$.data"
json_attributes:
- next_open
- next_close
value_json.data.is_open renders as True or False, which the RESTful binary sensor maps to on and off without any further template work. The sibling REST sensor stores the session state and transition attributes. Keep json_attributes on that sensor, not on the REST binary sensor, which does not support it. Both entities share the same REST resource and status request.
One REST sensor per ticker#
Each ticker is its own rest entry because each has its own URL. The scan_interval is set to a day, which is its normal background polling interval; the real refresh cadence comes from the automation in the next section, which calls homeassistant.update_entity while the cached market-open gate is on.
# configuration.yaml (continued, same rest: list)
- resource: https://api.sifting.io/v1/last/trade/stocks/AAPL
headers:
X-API-Key: !secret sifting_key
scan_interval: 86400
sensor:
- name: "AAPL price"
unique_id: sifting_last_trade_aapl
unit_of_measurement: "USD"
value_template: >-
{%- if value_json is defined and value_json.p is defined and value_json.p is is_number -%}
{{ value_json.p | float }}
{%- else -%}
{{ none }}
{%- endif -%}
availability: >-
{{ value_json is defined and value_json.p is defined
and value_json.p is is_number }}
json_attributes:
- s
- t
- resource: https://api.sifting.io/v1/last/trade/stocks/MSFT
headers:
X-API-Key: !secret sifting_key
scan_interval: 86400
sensor:
- name: "MSFT price"
unique_id: sifting_last_trade_msft
unit_of_measurement: "USD"
value_template: >-
{%- if value_json is defined and value_json.p is defined and value_json.p is is_number -%}
{{ value_json.p | float }}
{%- else -%}
{{ none }}
{%- endif -%}
availability: >-
{{ value_json is defined and value_json.p is defined
and value_json.p is is_number }}
json_attributes:
- s
- t
- resource: https://api.sifting.io/v1/last/trade/stocks/NVDA
headers:
X-API-Key: !secret sifting_key
scan_interval: 86400
sensor:
- name: "NVDA price"
unique_id: sifting_last_trade_nvda
unit_of_measurement: "USD"
value_template: >-
{%- if value_json is defined and value_json.p is defined and value_json.p is is_number -%}
{{ value_json.p | float }}
{%- else -%}
{{ none }}
{%- endif -%}
availability: >-
{{ value_json is defined and value_json.p is defined
and value_json.p is is_number }}
json_attributes:
- s
- t
The example creates AAPL, MSFT and NVDA sensors. To start with one ticker, keep only AAPL here and remove MSFT and NVDA from the automation and card below. For additional tickers, copy a resource entry and give it a different URL, name and unique_id.
The numeric guard rejects a missing or non-numeric price instead of inventing zero or showing an old value as a successful refresh. A transport failure or an HTTP error can make the REST entity unavailable before the value template runs; the template is not a way to override that. Check the Home Assistant log for authentication, quota or stale-snapshot errors. The age display below is meaningful only when a usable timestamp is available.
Gate price refreshes by market status#
The RESTful platform cannot be told to stop polling, but it can be told to poll rarely and then be forced to refresh on demand. For a REST sensor, homeassistant.update_entity requests a refresh without waiting for the background interval. So the automation fires on a time pattern and refreshes the price sensors only when the binary sensor is on.
automation:
- alias: "Refresh stock prices while the US market is open"
triggers:
- trigger: time_pattern
minutes: "/5"
conditions:
- condition: state
entity_id: binary_sensor.us_stock_market_open
state: "on"
actions:
- action: homeassistant.update_entity
data:
entity_id:
- sensor.aapl_price
- sensor.msft_price
- sensor.nvda_price
When the gate is off or unavailable, the automation skips its price requests. Because the status is cached, scheduled refreshes can continue for roughly 15 minutes after the close under normal operation. The daily background poll and startup fetch also run independently of this gate. This is a request-saving setup, not a strict guarantee of zero out-of-hours requests.
Show the age, not just the price#
Show the timestamp age alongside the price so an old reading is visible. The t attribute is the timestamp SiftingIO published the value under, in milliseconds, so a small template sensor can turn it into a readable age. It recomputes on a timer, because a template that only reacts to state changes would sit at "2 min" forever once the price stops changing.
If the price entity is unavailable, the label reports that first. Otherwise, the age is not a verdict on the price. An unchanged price after the close, an intentional delay, stale upstream data and a broken poll all look like "old" from the outside, so the sensor reports the age and two things that are not ages: a missing timestamp, and a timestamp ahead of Home Assistant's clock, which means one of the two clocks is wrong and the number should not be read as "live".
template:
- triggers:
- trigger: time_pattern
minutes: "/1"
- trigger: state
entity_id: sensor.aapl_price
sensor:
- name: "AAPL price age"
unique_id: sifting_aapl_price_age
state: >-
{%- set t = state_attr('sensor.aapl_price', 't') -%}
{%- if not has_value('sensor.aapl_price') -%}
unavailable
{%- elif t is not is_number or (t | float) <= 0 -%}
no timestamp
{%- else -%}
{%- set age = as_timestamp(now()) - (t | float / 1000) -%}
{%- if age < -30 -%}
clock skew
{%- elif age < 60 -%}
{{ ([age, 0] | max) | round(0) | int }} s
{%- elif age < 3600 -%}
{{ (age / 60) | round(0) | int }} min
{%- else -%}
{{ (age / 3600) | round(1) }} h
{%- endif -%}
{%- endif -%}
- triggers:
- trigger: time_pattern
minutes: "/1"
- trigger: state
entity_id: sensor.msft_price
sensor:
- name: "MSFT price age"
unique_id: sifting_msft_price_age
state: >-
{%- set t = state_attr('sensor.msft_price', 't') -%}
{%- if not has_value('sensor.msft_price') -%}
unavailable
{%- elif t is not is_number or (t | float) <= 0 -%}
no timestamp
{%- else -%}
{%- set age = as_timestamp(now()) - (t | float / 1000) -%}
{%- if age < -30 -%}
clock skew
{%- elif age < 60 -%}
{{ ([age, 0] | max) | round(0) | int }} s
{%- elif age < 3600 -%}
{{ (age / 60) | round(0) | int }} min
{%- else -%}
{{ (age / 3600) | round(1) }} h
{%- endif -%}
{%- endif -%}
- triggers:
- trigger: time_pattern
minutes: "/1"
- trigger: state
entity_id: sensor.nvda_price
sensor:
- name: "NVDA price age"
unique_id: sifting_nvda_price_age
state: >-
{%- set t = state_attr('sensor.nvda_price', 't') -%}
{%- if not has_value('sensor.nvda_price') -%}
unavailable
{%- elif t is not is_number or (t | float) <= 0 -%}
no timestamp
{%- else -%}
{%- set age = as_timestamp(now()) - (t | float / 1000) -%}
{%- if age < -30 -%}
clock skew
{%- elif age < 60 -%}
{{ ([age, 0] | max) | round(0) | int }} s
{%- elif age < 3600 -%}
{{ (age / 60) | round(0) | int }} min
{%- else -%}
{{ (age / 3600) | round(1) }} h
{%- endif -%}
{%- endif -%}
The template sensors above use the same age calculation for all three tickers. This example treats a timestamp more than 30 seconds ahead as clock skew and clamps smaller negative ages to zero. It does not synchronize either clock: a fast Home Assistant clock makes the displayed age too large. Keep the host clock synchronized and do not interpret this label as an execution-latency measurement.
The card#
An Entities card is enough. secondary_info: last_changed on each price row shows when Home Assistant last saw a different value, and the age rows show how old the published value is according to its own timestamp. Those are two different things and it is useful to see both.
type: entities
title: US stocks
entities:
- entity: binary_sensor.us_stock_market_open
name: Market
secondary_info: last_changed
- type: attribute
entity: sensor.us_stock_market_session
attribute: next_open
name: Next open (UTC)
- entity: sensor.aapl_price
name: AAPL
secondary_info: last_changed
- entity: sensor.aapl_price_age
name: AAPL age
- entity: sensor.msft_price
name: MSFT
secondary_info: last_changed
- entity: sensor.msft_price_age
name: MSFT age
- entity: sensor.nvda_price
name: NVDA
secondary_info: last_changed
- entity: sensor.nvda_price_age
name: NVDA age
Sizing the polls to the free tier#
The pricing page lists 10,000 REST calls a month, 60 requests per minute and one API key on Free, with no card required (checked 7 October 2026). Confirm the current limits and your account access before relying on this budget. The question is how the two polling loops above spend that budget.
The status sensor runs all day. At every 15 minutes:
| Loop | Calls |
|---|---|
| Status, 4 per hour, 24 h, 30 days | 2,880 |
For budgeting, use a full regular session of 6.5 hours: 78 five-minute refreshes per ticker. Allow three more for a status check that notices the close late, giving 81 scheduled refreshes per ticker per full trading day. This is a conservative planning estimate, not an exact request count. With three tickers and an assumed 21 trading days:
| Loop | Calls |
|---|---|
| Prices, 81 x 3 tickers x 21 days | 5,103 |
| Background daily price polls, 3 x 30 | 90 |
| Estimated total before startup fetches, retries and other clients | 8,073 |
For a 31-day month with 23 full trading days, the same assumptions give 2,976 status calls + 5,589 price refreshes + 93 background price polls = 8,658 calls. Startup fetches, retries, manual refreshes and other clients are extra. Three price requests per automation run are modest, but the rate limit applies to your combined usage, not only this automation.
To change the plan, recompute the price row. Each extra ticker costs about 1,701 scheduled refreshes plus 30 background polls in the 30-day, 21-session example. Changing from five-minute to one-minute refreshes multiplies the scheduled price requests by roughly five and needs a new budget. Polling status every 5 minutes instead of 15 would cost 8,640 on its own, which is why the status loop is the slow one and the price loop is the fast one, not the other way round. The trading-day count is an assumption; the market calendar (GET /v1/fnd/markets/us_equities/calendar) lists full closures and early closes for a date range if you want the exact number, and early closes reduce the scheduled price window once the gate refreshes.
Things that fail quietly#
A 401 means authentication failed: check that the secret exists and that the API key is active and sent as X-API-Key. A 403 needs the response body to diagnose; check market access, permissions and account restrictions rather than assuming that every 403 has one cause. Do not assume that a working request to one endpoint proves permission to every other endpoint.
The snapshot endpoint can return 503 stale_snapshot when it has no sufficiently fresh value, including outside active trading. This setup is not a guaranteed last-closing-price feed; an unavailable entity is preferable to labelling an old snapshot as current.
A numeric price sensor must not emit an error string. The template above produces a number or none, while availability requires a numeric price in the response. After an error, inspect both the entity status and its timestamp rather than treating a previous reading as newly fetched.
The binary sensor is cached by its own scan_interval, so for up to 15 minutes after the open the card still says closed and scheduled price refreshes are skipped. If that matters, drop the status interval to 10 minutes and take the extra 1,440 calls a month out of the price budget.
Do not assume the REST sensor automatically honors a Retry-After response header. Monitor API usage and Home Assistant errors, and reduce or suspend polling if you see 429 responses. These YAML examples do not implement an adaptive backoff controller. If you'd rather watch the same numbers in a spreadsheet than on a wall panel, the Excel Power Query guide uses the same endpoints from a spreadsheet's refresh button.
Start with one ticker, confirm the price, timestamp and market gate, then expand the watchlist within your request budget. Create your SiftingIO account or review US stock data access.



